mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 06:09:39 +08:00
@@ -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<boolean> => {
|
||||
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() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>启动窗口</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span>启动时全屏</span>
|
||||
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
* 修改后下次启动生效
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -619,6 +619,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
// 使用 ref 来优化拖拽性能,完全避免状态更新
|
||||
const cellSelectionRafRef = useRef<number | null>(null);
|
||||
const cellSelectionScrollRafRef = useRef<number | null>(null);
|
||||
const cellSelectionAutoScrollRafRef = useRef<number | null>(null);
|
||||
const cellSelectionPointerRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
// 导入预览 Modal 状态
|
||||
@@ -1102,6 +1104,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
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<DataGridProps> = ({
|
||||
|
||||
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<DataGridProps> = ({
|
||||
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<string, number>();
|
||||
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<DataGridProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
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<string, number>();
|
||||
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<DataGridProps> = ({
|
||||
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<DataGridProps> = ({
|
||||
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<DataGridProps> = ({
|
||||
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<DataGridProps> = ({
|
||||
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) {
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<DriverNetworkStatus | null>(null);
|
||||
const [rows, setRows] = useState<DriverStatusRow[]>([]);
|
||||
const [actionDriver, setActionDriver] = useState('');
|
||||
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
|
||||
const [operationLogMap, setOperationLogMap] = useState<Record<string, DriverLogEntry[]>>({});
|
||||
const [logDriverType, setLogDriverType] = useState('');
|
||||
const [logModalOpen, setLogModalOpen] = useState(false);
|
||||
const [versionMap, setVersionMap] = useState<Record<string, DriverVersionOption[]>>({});
|
||||
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
|
||||
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
|
||||
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
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<string, string>,
|
||||
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 <Text type="secondary">内置</Text>;
|
||||
}
|
||||
const installPath = row.executablePath || row.installDir || '-';
|
||||
if (installPath === '-') {
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
return (
|
||||
<Text copyable={{ text: installPath }} style={{ fontSize: 12 }}>
|
||||
{installPath}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 <Text type="secondary">-</Text>;
|
||||
@@ -521,19 +730,20 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
if (isSlimBuildUnavailable && !row.packageInstalled) {
|
||||
return <Text type="secondary">需 Full 版</Text>;
|
||||
}
|
||||
if (row.connectable) {
|
||||
return (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={loadingAction}
|
||||
onClick={() => removeDriver(row)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
const logs = operationLogMap[row.type] || [];
|
||||
const hasLogs = logs.length > 0;
|
||||
|
||||
const mainAction = row.connectable ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={loadingAction}
|
||||
onClick={() => removeDriver(row)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
@@ -543,10 +753,41 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
安装启用
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Space size={8} wrap>
|
||||
{mainAction}
|
||||
<Button
|
||||
icon={<FileSearchOutlined />}
|
||||
loading={loadingAction}
|
||||
onClick={() => installDriverFromLocalFile(row)}
|
||||
>
|
||||
本地导入
|
||||
</Button>
|
||||
<Button
|
||||
type={hasLogs ? 'default' : 'text'}
|
||||
disabled={!hasLogs}
|
||||
onClick={() => openDriverLog(row.type)}
|
||||
>
|
||||
日志
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [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 (
|
||||
<Modal
|
||||
@@ -554,11 +795,23 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={980}
|
||||
style={{ top: 24 }}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 18,
|
||||
},
|
||||
}}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
|
||||
刷新
|
||||
</Button>,
|
||||
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
|
||||
网络检测
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={onClose}>
|
||||
关闭
|
||||
</Button>,
|
||||
@@ -566,6 +819,67 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Text type="secondary">除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。</Text>
|
||||
{networkStatus ? (
|
||||
<Alert
|
||||
type={networkStatus.reachable ? 'success' : 'warning'}
|
||||
showIcon
|
||||
message={networkStatus.summary}
|
||||
description={(
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary">
|
||||
驱动下载依赖 GitHub 与 Go 模块代理网络。若检测失败,建议先启用 HTTP/HTTPS/SOCKS5 代理后重试。
|
||||
</Text>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'checks',
|
||||
label: '查看网络检测明细',
|
||||
children: (
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{networkStatus.checks.map((item) => (
|
||||
<Text key={`${item.name}-${item.url}`} type={item.reachable ? 'secondary' : 'danger'}>
|
||||
{item.name}:{item.reachable ? '可达' : '不可达'}{item.httpStatus ? `,HTTP ${item.httpStatus}` : ''}{item.latencyMs ? `,${item.latencyMs}ms` : ''}{item.error ? `,${item.error}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
{proxyEnvEntries.length > 0 ? (
|
||||
<Text type="secondary">
|
||||
检测到代理环境变量:{proxyEnvEntries.map(([key]) => key).join('、')}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary">未检测到系统代理环境变量。</Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="info" showIcon message={networkChecking ? '正在检测驱动下载网络...' : '尚未完成网络检测'} />
|
||||
)}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="驱动目录与复用说明"
|
||||
description={(
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary">自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。</Text>
|
||||
<Text type="secondary">手动导入支持单个驱动代理文件(如 `mariadb-driver-agent` / `mariadb-driver-agent.exe`)或驱动总包 `GoNavi-DriverAgents.zip`。</Text>
|
||||
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
|
||||
驱动根目录:{downloadDir || '-'}
|
||||
</Paragraph>
|
||||
{networkStatus?.logPath ? (
|
||||
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
|
||||
运行日志文件:{networkStatus.logPath}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Table
|
||||
rowKey="type"
|
||||
@@ -574,8 +888,40 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
scroll={{ x: 1450 }}
|
||||
/>
|
||||
</Space>
|
||||
<Modal
|
||||
title={`驱动日志 - ${activeLogRow?.name || logDriverType}`}
|
||||
open={logModalOpen}
|
||||
onCancel={() => setLogModalOpen(false)}
|
||||
footer={[
|
||||
<Button key="close-log" type="primary" onClick={() => setLogModalOpen(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={780}
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{activeLogRow?.installDir ? (
|
||||
<Paragraph copyable={{ text: activeLogRow.installDir }} style={{ marginBottom: 0 }}>
|
||||
安装目录:{activeLogRow.installDir}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
{activeLogRow?.executablePath ? (
|
||||
<Paragraph copyable={{ text: activeLogRow.executablePath }} style={{ marginBottom: 0 }}>
|
||||
驱动可执行文件:{activeLogRow.executablePath}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
{activeDriverLogLines.length > 0 ? (
|
||||
<pre style={{ margin: 0, maxHeight: 360, overflow: 'auto', padding: 12, background: '#fafafa', borderRadius: 8, border: '1px solid #f0f0f0', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{activeDriverLogLines.join('\n')}
|
||||
</pre>
|
||||
) : (
|
||||
<Text type="secondary">当前驱动暂无操作日志。</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<RedisViewerProps> = ({ 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<string, RedisKeyInfo>();
|
||||
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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
</Spin>
|
||||
{hasMore && (
|
||||
<div style={{ padding: 8, textAlign: 'center' }}>
|
||||
<Button onClick={handleLoadMore} loading={loading}>加载更多</Button>
|
||||
<Button onClick={handleLoadMore} loading={loading} disabled={!hasMore || loading}>加载更多</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string>('');
|
||||
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
|
||||
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
|
||||
const [batchFilterKeyword, setBatchFilterKeyword] = useState<string>('');
|
||||
const [batchFilterType, setBatchFilterType] = useState<BatchObjectFilterType>('all');
|
||||
const [batchSelectionScope, setBatchSelectionScope] = useState<BatchSelectionScope>('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 }>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batchTables.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap size={8} style={{ width: '100%' }}>
|
||||
<Input
|
||||
allowClear
|
||||
value={batchFilterKeyword}
|
||||
onChange={(e) => setBatchFilterKeyword(e.target.value)}
|
||||
placeholder="筛选表/视图名称"
|
||||
prefix={<SearchOutlined />}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Select
|
||||
value={batchFilterType}
|
||||
onChange={(value) => setBatchFilterType(value as BatchObjectFilterType)}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: '全部对象', value: 'all' },
|
||||
{ label: '仅表', value: 'table' },
|
||||
{ label: '仅视图', value: 'view' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={batchSelectionScope}
|
||||
onChange={(value) => setBatchSelectionScope(value as BatchSelectionScope)}
|
||||
style={{ width: 220 }}
|
||||
options={[
|
||||
{ label: '勾选作用于:当前筛选结果', value: 'filtered' },
|
||||
{ label: '勾选作用于:全部对象', value: 'all' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ marginTop: 6, color: '#999', fontSize: 12 }}>
|
||||
当前筛选命中 {filteredBatchObjects.length} / {batchTables.length} 个对象
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchTables.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
@@ -2881,18 +2992,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCheckAll(true)}
|
||||
disabled={selectionScopeTargetKeys.length === 0}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCheckAll(false)}
|
||||
disabled={selectionScopeTargetKeys.length === 0}
|
||||
>
|
||||
取消全选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleInvertSelection}
|
||||
disabled={selectionScopeTargetKeys.length === 0}
|
||||
>
|
||||
反选
|
||||
</Button>
|
||||
@@ -2938,6 +3052,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{groupedBatchObjects.tables.length === 0 && groupedBatchObjects.views.length === 0 && (
|
||||
<div style={{ color: '#999', padding: '8px 0' }}>
|
||||
无匹配对象
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -206,10 +207,27 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
return safeConfig;
|
||||
};
|
||||
|
||||
const resolveConnectionConfigPayload = (raw: Record<string, unknown>): 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<string, unknown>;
|
||||
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;
|
||||
@@ -278,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[];
|
||||
@@ -304,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<QueryOptions>) => void;
|
||||
|
||||
@@ -392,6 +412,21 @@ const sanitizeAppearance = (
|
||||
return nextAppearance;
|
||||
};
|
||||
|
||||
const sanitizeStartupFullscreen = (value: unknown): boolean => {
|
||||
return value === true;
|
||||
};
|
||||
|
||||
const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknown> => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return {};
|
||||
}
|
||||
const raw = persistedState as Record<string, unknown>;
|
||||
if (raw.state && typeof raw.state === 'object') {
|
||||
return raw.state as Record<string, unknown>;
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
export const useStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
@@ -402,6 +437,7 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: [],
|
||||
theme: 'light',
|
||||
appearance: { ...DEFAULT_APPEARANCE },
|
||||
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
|
||||
sqlFormatOptions: { keywordCase: 'upper' },
|
||||
queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true },
|
||||
sqlLogs: [],
|
||||
@@ -513,6 +549,7 @@ export const useStore = create<AppState>()(
|
||||
|
||||
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 } })),
|
||||
|
||||
@@ -544,15 +581,13 @@ export const useStore = create<AppState>()(
|
||||
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<AppState>;
|
||||
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
|
||||
const nextState: Partial<AppState> = { ...state };
|
||||
nextState.connections = sanitizeConnections(state.connections);
|
||||
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);
|
||||
@@ -560,9 +595,7 @@ export const useStore = create<AppState>()(
|
||||
return nextState as AppState;
|
||||
},
|
||||
merge: (persistedState, currentState) => {
|
||||
const state = (persistedState && typeof persistedState === 'object')
|
||||
? persistedState as Partial<AppState>
|
||||
: {};
|
||||
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
|
||||
return {
|
||||
...currentState,
|
||||
...state,
|
||||
@@ -570,6 +603,7 @@ export const useStore = create<AppState>()(
|
||||
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),
|
||||
@@ -581,6 +615,7 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: state.savedQueries,
|
||||
theme: state.theme,
|
||||
appearance: state.appearance,
|
||||
startupFullscreen: state.startupFullscreen,
|
||||
sqlFormatOptions: state.sqlFormatOptions,
|
||||
queryOptions: state.queryOptions,
|
||||
tableAccessCount: state.tableAccessCount,
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -6,6 +6,8 @@ import {redis} from '../models';
|
||||
|
||||
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckForUpdates():Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
1
main.go
1
main.go
@@ -41,6 +41,7 @@ func main() {
|
||||
BackdropType: windows.Acrylic,
|
||||
DisableWindowIcon: false,
|
||||
DisableFramelessWindowDecorations: false,
|
||||
WebviewUserDataPath: resolveWindowsWebviewUserDataPath(),
|
||||
},
|
||||
Mac: &mac.Options{
|
||||
WebviewIsTransparent: true,
|
||||
|
||||
123
main_windows_webview_userdata.go
Normal file
123
main_windows_webview_userdata.go
Normal file
@@ -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
|
||||
}
|
||||
7
main_windows_webview_userdata_stub.go
Normal file
7
main_windows_webview_userdata_stub.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func resolveWindowsWebviewUserDataPath() string {
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user