mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-07 11:01:33 +08:00
Merge branch 'release/0.4.8'
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 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 { 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 Sidebar from './components/Sidebar';
|
||||||
import TabManager from './components/TabManager';
|
import TabManager from './components/TabManager';
|
||||||
import ConnectionModal from './components/ConnectionModal';
|
import ConnectionModal from './components/ConnectionModal';
|
||||||
@@ -26,6 +26,8 @@ function App() {
|
|||||||
const setTheme = useStore(state => state.setTheme);
|
const setTheme = useStore(state => state.setTheme);
|
||||||
const appearance = useStore(state => state.appearance);
|
const appearance = useStore(state => state.appearance);
|
||||||
const setAppearance = useStore(state => state.setAppearance);
|
const setAppearance = useStore(state => state.setAppearance);
|
||||||
|
const startupFullscreen = useStore(state => state.startupFullscreen);
|
||||||
|
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
|
||||||
const darkMode = themeMode === 'dark';
|
const darkMode = themeMode === 'dark';
|
||||||
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
|
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||||
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
|
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
|
// Background Helper
|
||||||
const getBg = (darkHex: string, lightHex: string) => {
|
const getBg = (darkHex: string, lightHex: string) => {
|
||||||
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
|
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
|
||||||
@@ -915,6 +995,16 @@ function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -619,6 +619,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
// 使用 ref 来优化拖拽性能,完全避免状态更新
|
// 使用 ref 来优化拖拽性能,完全避免状态更新
|
||||||
const cellSelectionRafRef = useRef<number | null>(null);
|
const cellSelectionRafRef = useRef<number | null>(null);
|
||||||
const cellSelectionScrollRafRef = 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);
|
const isDraggingRef = useRef(false);
|
||||||
|
|
||||||
// 导入预览 Modal 状态
|
// 导入预览 Modal 状态
|
||||||
@@ -1102,6 +1104,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
currentSelectionRef.current = new Set();
|
currentSelectionRef.current = new Set();
|
||||||
selectionStartRef.current = null;
|
selectionStartRef.current = null;
|
||||||
isDraggingRef.current = false;
|
isDraggingRef.current = false;
|
||||||
|
cellSelectionPointerRef.current = null;
|
||||||
|
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||||
|
cellSelectionAutoScrollRafRef.current = null;
|
||||||
|
}
|
||||||
updateCellSelection(new Set());
|
updateCellSelection(new Set());
|
||||||
}, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]);
|
}, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]);
|
||||||
|
|
||||||
@@ -1111,8 +1118,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
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;
|
const td = target.closest('td[data-row-key][data-col-name]') as HTMLElement;
|
||||||
if (!td) return null;
|
if (!td) return null;
|
||||||
const rowKey = td.getAttribute('data-row-key');
|
const rowKey = td.getAttribute('data-row-key');
|
||||||
@@ -1121,35 +1132,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
return { rowKey, colName };
|
return { rowKey, colName };
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMouseDown = (e: MouseEvent) => {
|
const getCellInfoFromPoint = (x: number, y: number): { rowKey: string; colName: string } | null => {
|
||||||
const cellInfo = getCellInfo(e.target as HTMLElement);
|
const target = document.elementFromPoint(x, y) as HTMLElement | null;
|
||||||
if (!cellInfo) return;
|
return getCellInfo(target);
|
||||||
|
|
||||||
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 onMouseMove = (e: MouseEvent) => {
|
const scheduleSelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => {
|
||||||
if (!isDraggingRef.current || !selectionStartRef.current) return;
|
|
||||||
|
|
||||||
const cellInfo = getCellInfo(e.target as HTMLElement);
|
|
||||||
if (!cellInfo) return;
|
|
||||||
|
|
||||||
// 使用 RAF 节流
|
|
||||||
if (cellSelectionRafRef.current !== null) {
|
if (cellSelectionRafRef.current !== null) {
|
||||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
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 = () => {
|
const onMouseUp = () => {
|
||||||
if (!isDraggingRef.current) return;
|
if (!isDraggingRef.current) return;
|
||||||
isDraggingRef.current = false;
|
isDraggingRef.current = false;
|
||||||
|
cellSelectionPointerRef.current = null;
|
||||||
|
stopAutoScroll();
|
||||||
|
|
||||||
if (cellSelectionRafRef.current !== null) {
|
if (cellSelectionRafRef.current !== null) {
|
||||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||||
@@ -1231,6 +1334,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
||||||
cellSelectionScrollRafRef.current = null;
|
cellSelectionScrollRafRef.current = null;
|
||||||
}
|
}
|
||||||
|
stopAutoScroll();
|
||||||
|
cellSelectionPointerRef.current = null;
|
||||||
isDraggingRef.current = false;
|
isDraggingRef.current = false;
|
||||||
};
|
};
|
||||||
}, [cellEditMode, columnNames, columnIndexMap, updateCellSelection]);
|
}, [cellEditMode, columnNames, columnIndexMap, updateCellSelection]);
|
||||||
@@ -2332,6 +2437,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
currentSelectionRef.current = new Set();
|
currentSelectionRef.current = new Set();
|
||||||
selectionStartRef.current = null;
|
selectionStartRef.current = null;
|
||||||
isDraggingRef.current = false;
|
isDraggingRef.current = false;
|
||||||
|
cellSelectionPointerRef.current = null;
|
||||||
if (cellSelectionRafRef.current !== null) {
|
if (cellSelectionRafRef.current !== null) {
|
||||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||||
cellSelectionRafRef.current = null;
|
cellSelectionRafRef.current = null;
|
||||||
@@ -2340,6 +2446,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
||||||
cellSelectionScrollRafRef.current = null;
|
cellSelectionScrollRafRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||||
|
cellSelectionAutoScrollRafRef.current = null;
|
||||||
|
}
|
||||||
updateCellSelection(new Set());
|
updateCellSelection(new Set());
|
||||||
if (!next) setBatchEditModalOpen(false);
|
if (!next) setBatchEditModalOpen(false);
|
||||||
message.info(next ? '已进入单元格编辑模式,可拖拽选择多个单元格' : '已退出单元格编辑模式');
|
message.info(next ? '已进入单元格编辑模式,可拖拽选择多个单元格' : '已退出单元格编辑模式');
|
||||||
@@ -2403,12 +2513,26 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
const nextMode = String(val) as GridViewMode;
|
const nextMode = String(val) as GridViewMode;
|
||||||
if (nextMode === 'json' && cellEditMode) {
|
if (nextMode === 'json' && cellEditMode) {
|
||||||
setCellEditMode(false);
|
setCellEditMode(false);
|
||||||
setSelectedCells(new Set());
|
setSelectedCells(new Set());
|
||||||
currentSelectionRef.current = new Set();
|
currentSelectionRef.current = new Set();
|
||||||
selectionStartRef.current = null;
|
selectionStartRef.current = null;
|
||||||
updateCellSelection(new Set());
|
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') {
|
if (nextMode === 'text') {
|
||||||
const selectedKey = selectedRowKeys[0];
|
const selectedKey = selectedRowKeys[0];
|
||||||
if (selectedKey !== undefined) {
|
if (selectedKey !== undefined) {
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Button, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
|
import { Alert, Button, Collapse, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||||
import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
import {
|
import {
|
||||||
|
CheckDriverNetworkStatus,
|
||||||
DownloadDriverPackage,
|
DownloadDriverPackage,
|
||||||
GetDriverVersionList,
|
GetDriverVersionList,
|
||||||
GetDriverVersionPackageSize,
|
GetDriverVersionPackageSize,
|
||||||
GetDriverStatusList,
|
GetDriverStatusList,
|
||||||
|
InstallLocalDriverPackage,
|
||||||
RemoveDriverPackage,
|
RemoveDriverPackage,
|
||||||
|
SelectDriverPackageFile,
|
||||||
} from '../../wailsjs/go/app/App';
|
} from '../../wailsjs/go/app/App';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Paragraph, Text } = Typography;
|
||||||
|
|
||||||
type DriverStatusRow = {
|
type DriverStatusRow = {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -23,6 +26,10 @@ type DriverStatusRow = {
|
|||||||
packageInstalled: boolean;
|
packageInstalled: boolean;
|
||||||
connectable: boolean;
|
connectable: boolean;
|
||||||
defaultDownloadUrl?: string;
|
defaultDownloadUrl?: string;
|
||||||
|
installDir?: string;
|
||||||
|
packagePath?: string;
|
||||||
|
executablePath?: string;
|
||||||
|
downloadedAt?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,6 +46,32 @@ type ProgressState = {
|
|||||||
percent: number;
|
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 = {
|
type DriverVersionOption = {
|
||||||
version: string;
|
version: string;
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
@@ -101,14 +134,65 @@ const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
|
|||||||
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [downloadDir, setDownloadDir] = useState('');
|
const [downloadDir, setDownloadDir] = useState('');
|
||||||
|
const [networkChecking, setNetworkChecking] = useState(false);
|
||||||
|
const [networkStatus, setNetworkStatus] = useState<DriverNetworkStatus | null>(null);
|
||||||
const [rows, setRows] = useState<DriverStatusRow[]>([]);
|
const [rows, setRows] = useState<DriverStatusRow[]>([]);
|
||||||
const [actionDriver, setActionDriver] = useState('');
|
const [actionDriver, setActionDriver] = useState('');
|
||||||
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
|
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 [versionMap, setVersionMap] = useState<Record<string, DriverVersionOption[]>>({});
|
||||||
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
|
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
|
||||||
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
|
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
|
||||||
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = 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) => {
|
const refreshStatus = useCallback(async (toastOnError = true) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -139,6 +223,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
packageInstalled: !!item.packageInstalled,
|
packageInstalled: !!item.packageInstalled,
|
||||||
connectable: !!item.connectable,
|
connectable: !!item.connectable,
|
||||||
defaultDownloadUrl: String(item.defaultDownloadUrl || '').trim() || undefined,
|
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,
|
message: String(item.message || '').trim() || undefined,
|
||||||
}));
|
}));
|
||||||
setRows(nextRows);
|
setRows(nextRows);
|
||||||
@@ -151,6 +239,45 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
}
|
}
|
||||||
}, [downloadDir]);
|
}, [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) => {
|
const loadVersionOptions = useCallback(async (row: DriverStatusRow, toastOnError = false) => {
|
||||||
if (row.builtIn) {
|
if (row.builtIn) {
|
||||||
return [] as DriverVersionOption[];
|
return [] as DriverVersionOption[];
|
||||||
@@ -305,7 +432,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
refreshStatus(false);
|
refreshStatus(false);
|
||||||
}, [open, refreshStatus]);
|
checkNetworkStatus(false);
|
||||||
|
}, [checkNetworkStatus, open, refreshStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -330,11 +458,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
percent,
|
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 () => {
|
return () => {
|
||||||
off();
|
off();
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [appendOperationLog, open]);
|
||||||
|
|
||||||
const installDriver = useCallback(async (row: DriverStatusRow) => {
|
const installDriver = useCallback(async (row: DriverStatusRow) => {
|
||||||
setActionDriver(row.type);
|
setActionDriver(row.type);
|
||||||
@@ -346,6 +479,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
percent: 0,
|
percent: 0,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
appendOperationLog(row.type, '[START] 开始自动安装');
|
||||||
try {
|
try {
|
||||||
let options = versionMap[row.type] || [];
|
let options = versionMap[row.type] || [];
|
||||||
if (options.length === 0) {
|
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);
|
const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir);
|
||||||
if (!result?.success) {
|
if (!result?.success) {
|
||||||
message.error(result?.message || `安装 ${row.name} 失败`);
|
const errText = result?.message || `安装 ${row.name} 失败`;
|
||||||
|
appendOperationLog(row.type, `[ERROR] ${errText}`);
|
||||||
|
message.error(errText);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const versionTip = selectedVersion ? `(${selectedVersion})` : '';
|
const versionTip = selectedVersion ? `(${selectedVersion})` : '';
|
||||||
|
appendOperationLog(row.type, `[DONE] 自动安装完成 ${versionTip}`);
|
||||||
message.success(`${row.name}${versionTip} 已安装启用`);
|
message.success(`${row.name}${versionTip} 已安装启用`);
|
||||||
refreshStatus(false);
|
refreshStatus(false);
|
||||||
} finally {
|
} finally {
|
||||||
setActionDriver('');
|
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) => {
|
const removeDriver = useCallback(async (row: DriverStatusRow) => {
|
||||||
setActionDriver(row.type);
|
setActionDriver(row.type);
|
||||||
|
appendOperationLog(row.type, '[START] 开始移除驱动');
|
||||||
try {
|
try {
|
||||||
const result = await RemoveDriverPackage(row.type, downloadDir);
|
const result = await RemoveDriverPackage(row.type, downloadDir);
|
||||||
if (!result?.success) {
|
if (!result?.success) {
|
||||||
message.error(result?.message || `移除 ${row.name} 失败`);
|
const errText = result?.message || `移除 ${row.name} 失败`;
|
||||||
|
appendOperationLog(row.type, `[ERROR] ${errText}`);
|
||||||
|
message.error(errText);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
appendOperationLog(row.type, '[DONE] 驱动移除完成');
|
||||||
message.success(`${row.name} 已移除`);
|
message.success(`${row.name} 已移除`);
|
||||||
setProgressMap((prev) => {
|
setProgressMap((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
@@ -390,7 +580,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setActionDriver('');
|
setActionDriver('');
|
||||||
}
|
}
|
||||||
}, [downloadDir, refreshStatus]);
|
}, [appendOperationLog, downloadDir, refreshStatus]);
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -400,6 +590,25 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
key: 'name',
|
key: 'name',
|
||||||
width: 150,
|
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: '安装包大小',
|
title: '安装包大小',
|
||||||
dataIndex: 'packageSizeText',
|
dataIndex: 'packageSizeText',
|
||||||
@@ -511,7 +720,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: 190,
|
width: 320,
|
||||||
render: (_: string, row: DriverStatusRow) => {
|
render: (_: string, row: DriverStatusRow) => {
|
||||||
if (row.builtIn) {
|
if (row.builtIn) {
|
||||||
return <Text type="secondary">-</Text>;
|
return <Text type="secondary">-</Text>;
|
||||||
@@ -521,19 +730,20 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
if (isSlimBuildUnavailable && !row.packageInstalled) {
|
if (isSlimBuildUnavailable && !row.packageInstalled) {
|
||||||
return <Text type="secondary">需 Full 版</Text>;
|
return <Text type="secondary">需 Full 版</Text>;
|
||||||
}
|
}
|
||||||
if (row.connectable) {
|
|
||||||
return (
|
const logs = operationLogMap[row.type] || [];
|
||||||
<Button
|
const hasLogs = logs.length > 0;
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
const mainAction = row.connectable ? (
|
||||||
loading={loadingAction}
|
<Button
|
||||||
onClick={() => removeDriver(row)}
|
danger
|
||||||
>
|
icon={<DeleteOutlined />}
|
||||||
移除
|
loading={loadingAction}
|
||||||
</Button>
|
onClick={() => removeDriver(row)}
|
||||||
);
|
>
|
||||||
}
|
移除
|
||||||
return (
|
</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<DownloadOutlined />}
|
icon={<DownloadOutlined />}
|
||||||
@@ -543,10 +753,41 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
安装启用
|
安装启用
|
||||||
</Button>
|
</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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -554,11 +795,23 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
width={980}
|
width={980}
|
||||||
|
style={{ top: 24 }}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
maxHeight: 'calc(100vh - 220px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
paddingRight: 18,
|
||||||
|
},
|
||||||
|
}}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
|
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
|
||||||
刷新
|
刷新
|
||||||
</Button>,
|
</Button>,
|
||||||
|
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
|
||||||
|
网络检测
|
||||||
|
</Button>,
|
||||||
<Button key="close" type="primary" onClick={onClose}>
|
<Button key="close" type="primary" onClick={onClose}>
|
||||||
关闭
|
关闭
|
||||||
</Button>,
|
</Button>,
|
||||||
@@ -566,6 +819,67 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
>
|
>
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
<Text type="secondary">除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。</Text>
|
<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
|
<Table
|
||||||
rowKey="type"
|
rowKey="type"
|
||||||
@@ -574,8 +888,40 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
dataSource={rows}
|
dataSource={rows}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="middle"
|
size="middle"
|
||||||
|
scroll={{ x: 1450 }}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</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>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const REDIS_TREE_KEY_TYPE_WIDTH = 92;
|
|||||||
const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84;
|
const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84;
|
||||||
const REDIS_TREE_KEY_TTL_WIDTH = 92;
|
const REDIS_TREE_KEY_TTL_WIDTH = 92;
|
||||||
const REDIS_TREE_HIDE_TTL_THRESHOLD = 460;
|
const REDIS_TREE_HIDE_TTL_THRESHOLD = 460;
|
||||||
|
const REDIS_KEY_INITIAL_LOAD_COUNT = 2000;
|
||||||
|
const REDIS_KEY_LOAD_MORE_COUNT = 2000;
|
||||||
|
|
||||||
interface RedisViewerProps {
|
interface RedisViewerProps {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
@@ -462,27 +464,34 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
|||||||
};
|
};
|
||||||
}, [connection, 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();
|
const config = getConfig();
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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) {
|
if (res.success) {
|
||||||
const result = res.data;
|
const result = res.data;
|
||||||
|
const scannedKeys = Array.isArray(result?.keys) ? result.keys : [];
|
||||||
|
const nextCursor = Number(result?.cursor || 0);
|
||||||
if (append) {
|
if (append) {
|
||||||
setKeys(prev => {
|
setKeys(prev => {
|
||||||
const keyMap = new Map<string, RedisKeyInfo>();
|
const keyMap = new Map<string, RedisKeyInfo>();
|
||||||
prev.forEach(item => keyMap.set(item.key, item));
|
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());
|
return Array.from(keyMap.values());
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setKeys(result.keys);
|
setKeys(scannedKeys);
|
||||||
}
|
}
|
||||||
setCursor(result.cursor);
|
setCursor(nextCursor);
|
||||||
setHasMore(result.cursor !== 0);
|
setHasMore(nextCursor !== 0);
|
||||||
} else {
|
} else {
|
||||||
message.error('加载 Key 失败: ' + res.message);
|
message.error('加载 Key 失败: ' + res.message);
|
||||||
}
|
}
|
||||||
@@ -494,23 +503,26 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
|||||||
}, [getConfig]);
|
}, [getConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadKeys(searchPattern, 0, false);
|
loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
|
||||||
}, [redisDB]);
|
}, [redisDB]);
|
||||||
|
|
||||||
const handleSearch = (value: string) => {
|
const handleSearch = (value: string) => {
|
||||||
const pattern = value.trim() || '*';
|
const pattern = value.trim() || '*';
|
||||||
setSearchPattern(pattern);
|
setSearchPattern(pattern);
|
||||||
setCursor(0);
|
setCursor(0);
|
||||||
loadKeys(pattern, 0, false);
|
loadKeys(pattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
loadKeys(searchPattern, cursor, true);
|
if (!hasMore || loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadKeys(searchPattern, cursor, true, REDIS_KEY_LOAD_MORE_COUNT);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setCursor(0);
|
setCursor(0);
|
||||||
loadKeys(searchPattern, 0, false);
|
loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadKeyValue = async (key: string) => {
|
const loadKeyValue = async (key: string) => {
|
||||||
@@ -1777,7 +1789,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
|||||||
</Spin>
|
</Spin>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div style={{ padding: 8, textAlign: 'center' }}>
|
<div style={{ padding: 8, textAlign: 'center' }}>
|
||||||
<Button onClick={handleLoadMore} loading={loading}>加载更多</Button>
|
<Button onClick={handleLoadMore} loading={loading} disabled={!hasMore || loading}>加载更多</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ interface TreeNode {
|
|||||||
|
|
||||||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||||
type BatchObjectType = 'table' | 'view';
|
type BatchObjectType = 'table' | 'view';
|
||||||
|
type BatchObjectFilterType = 'all' | BatchObjectType;
|
||||||
|
type BatchSelectionScope = 'filtered' | 'all';
|
||||||
|
|
||||||
interface BatchObjectItem {
|
interface BatchObjectItem {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -133,11 +135,47 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
const [selectedConnection, setSelectedConnection] = useState<string>('');
|
const [selectedConnection, setSelectedConnection] = useState<string>('');
|
||||||
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
|
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
|
||||||
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
|
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 groupedBatchObjects = useMemo(() => {
|
||||||
const tables = batchTables.filter(item => item.objectType === 'table');
|
const tables = filteredBatchObjects.filter(item => item.objectType === 'table');
|
||||||
const views = batchTables.filter(item => item.objectType === 'view');
|
const views = filteredBatchObjects.filter(item => item.objectType === 'view');
|
||||||
return { tables, views };
|
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
|
// Batch Database Operations Modal
|
||||||
const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false);
|
const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false);
|
||||||
@@ -1313,6 +1351,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
setBatchTables([]);
|
setBatchTables([]);
|
||||||
setCheckedTableKeys([]);
|
setCheckedTableKeys([]);
|
||||||
setAvailableDatabases([]);
|
setAvailableDatabases([]);
|
||||||
|
setBatchFilterKeyword('');
|
||||||
|
setBatchFilterType('all');
|
||||||
|
setBatchSelectionScope('filtered');
|
||||||
|
|
||||||
if (connId) {
|
if (connId) {
|
||||||
const conn = connections.find(c => c.id === connId);
|
const conn = connections.find(c => c.id === connId);
|
||||||
@@ -1413,6 +1454,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
setSelectedDatabase('');
|
setSelectedDatabase('');
|
||||||
setBatchTables([]);
|
setBatchTables([]);
|
||||||
setCheckedTableKeys([]);
|
setCheckedTableKeys([]);
|
||||||
|
setBatchFilterKeyword('');
|
||||||
|
setBatchFilterType('all');
|
||||||
|
setBatchSelectionScope('filtered');
|
||||||
|
|
||||||
const conn = connections.find(c => c.id === connId);
|
const conn = connections.find(c => c.id === connId);
|
||||||
if (conn) {
|
if (conn) {
|
||||||
@@ -1422,6 +1466,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
|
|
||||||
const handleDatabaseChange = async (dbName: string) => {
|
const handleDatabaseChange = async (dbName: string) => {
|
||||||
setSelectedDatabase(dbName);
|
setSelectedDatabase(dbName);
|
||||||
|
setBatchFilterKeyword('');
|
||||||
|
setBatchFilterType('all');
|
||||||
|
setBatchSelectionScope('filtered');
|
||||||
|
|
||||||
const conn = connections.find(c => c.id === selectedConnection);
|
const conn = connections.find(c => c.id === selectedConnection);
|
||||||
if (conn && dbName) {
|
if (conn && dbName) {
|
||||||
@@ -1470,17 +1517,44 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckAll = (checked: boolean) => {
|
const handleCheckAll = (checked: boolean) => {
|
||||||
if (checked) {
|
if (batchSelectionScope === 'all') {
|
||||||
setCheckedTableKeys(batchTables.map(t => t.key));
|
setCheckedTableKeys(checked ? allBatchObjectKeys : []);
|
||||||
} else {
|
return;
|
||||||
setCheckedTableKeys([]);
|
|
||||||
}
|
}
|
||||||
|
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 handleInvertSelection = () => {
|
||||||
const allKeys = batchTables.map(t => t.key);
|
if (batchSelectionScope === 'all') {
|
||||||
const newChecked = allKeys.filter(k => !checkedTableKeys.includes(k));
|
setCheckedTableKeys(prev => allBatchObjectKeys.filter((key) => !prev.includes(key)));
|
||||||
setCheckedTableKeys(newChecked);
|
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 () => {
|
const openBatchDatabaseModal = async () => {
|
||||||
@@ -2874,6 +2948,43 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{batchTables.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
@@ -2881,18 +2992,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => handleCheckAll(true)}
|
onClick={() => handleCheckAll(true)}
|
||||||
|
disabled={selectionScopeTargetKeys.length === 0}
|
||||||
>
|
>
|
||||||
全选
|
全选
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => handleCheckAll(false)}
|
onClick={() => handleCheckAll(false)}
|
||||||
|
disabled={selectionScopeTargetKeys.length === 0}
|
||||||
>
|
>
|
||||||
取消全选
|
取消全选
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleInvertSelection}
|
onClick={handleInvertSelection}
|
||||||
|
disabled={selectionScopeTargetKeys.length === 0}
|
||||||
>
|
>
|
||||||
反选
|
反选
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2938,6 +3052,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{groupedBatchObjects.tables.length === 0 && groupedBatchObjects.views.length === 0 && (
|
||||||
|
<div style={{ color: '#999', padding: '8px 0' }}>
|
||||||
|
无匹配对象
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Checkbox.Group>
|
</Checkbox.Group>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
|
|||||||
import { ConnectionConfig, SavedConnection, TabData, SavedQuery } from './types';
|
import { ConnectionConfig, SavedConnection, TabData, SavedQuery } from './types';
|
||||||
|
|
||||||
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
|
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
|
||||||
|
const DEFAULT_STARTUP_FULLSCREEN = false;
|
||||||
const LEGACY_DEFAULT_OPACITY = 0.95;
|
const LEGACY_DEFAULT_OPACITY = 0.95;
|
||||||
const OPACITY_EPSILON = 1e-6;
|
const OPACITY_EPSILON = 1e-6;
|
||||||
const MAX_URI_LENGTH = 4096;
|
const MAX_URI_LENGTH = 4096;
|
||||||
@@ -206,10 +207,27 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
|||||||
return safeConfig;
|
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 => {
|
const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection | null => {
|
||||||
if (!value || typeof value !== 'object') return null;
|
if (!value || typeof value !== 'object') return null;
|
||||||
const raw = value as Record<string, unknown>;
|
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 id = toTrimmedString(raw.id, `conn-${index + 1}`) || `conn-${index + 1}`;
|
||||||
const fallbackName = config.host ? `${config.type}-${config.host}` : `连接-${index + 1}`;
|
const fallbackName = config.host ? `${config.type}-${config.host}` : `连接-${index + 1}`;
|
||||||
const name = toTrimmedString(raw.name, fallbackName) || fallbackName;
|
const name = toTrimmedString(raw.name, fallbackName) || fallbackName;
|
||||||
@@ -278,6 +296,7 @@ interface AppState {
|
|||||||
savedQueries: SavedQuery[];
|
savedQueries: SavedQuery[];
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
appearance: { opacity: number; blur: number };
|
appearance: { opacity: number; blur: number };
|
||||||
|
startupFullscreen: boolean;
|
||||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||||
queryOptions: QueryOptions;
|
queryOptions: QueryOptions;
|
||||||
sqlLogs: SqlLog[];
|
sqlLogs: SqlLog[];
|
||||||
@@ -304,6 +323,7 @@ interface AppState {
|
|||||||
|
|
||||||
setTheme: (theme: 'light' | 'dark') => void;
|
setTheme: (theme: 'light' | 'dark') => void;
|
||||||
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
|
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
|
||||||
|
setStartupFullscreen: (enabled: boolean) => void;
|
||||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||||
setQueryOptions: (options: Partial<QueryOptions>) => void;
|
setQueryOptions: (options: Partial<QueryOptions>) => void;
|
||||||
|
|
||||||
@@ -392,6 +412,21 @@ const sanitizeAppearance = (
|
|||||||
return nextAppearance;
|
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>()(
|
export const useStore = create<AppState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
@@ -402,6 +437,7 @@ export const useStore = create<AppState>()(
|
|||||||
savedQueries: [],
|
savedQueries: [],
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
appearance: { ...DEFAULT_APPEARANCE },
|
appearance: { ...DEFAULT_APPEARANCE },
|
||||||
|
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
|
||||||
sqlFormatOptions: { keywordCase: 'upper' },
|
sqlFormatOptions: { keywordCase: 'upper' },
|
||||||
queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true },
|
queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true },
|
||||||
sqlLogs: [],
|
sqlLogs: [],
|
||||||
@@ -513,6 +549,7 @@ export const useStore = create<AppState>()(
|
|||||||
|
|
||||||
setTheme: (theme) => set({ theme }),
|
setTheme: (theme) => set({ theme }),
|
||||||
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
|
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
|
||||||
|
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
|
||||||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||||
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...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)
|
name: 'lite-db-storage', // name of the item in the storage (must be unique)
|
||||||
version: 3,
|
version: 3,
|
||||||
migrate: (persistedState: unknown, version: number) => {
|
migrate: (persistedState: unknown, version: number) => {
|
||||||
if (!persistedState || typeof persistedState !== 'object') {
|
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
|
||||||
return persistedState as AppState;
|
|
||||||
}
|
|
||||||
const state = persistedState as Partial<AppState>;
|
|
||||||
const nextState: Partial<AppState> = { ...state };
|
const nextState: Partial<AppState> = { ...state };
|
||||||
nextState.connections = sanitizeConnections(state.connections);
|
nextState.connections = sanitizeConnections(state.connections);
|
||||||
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
|
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
|
||||||
nextState.theme = sanitizeTheme(state.theme);
|
nextState.theme = sanitizeTheme(state.theme);
|
||||||
nextState.appearance = sanitizeAppearance(state.appearance, version);
|
nextState.appearance = sanitizeAppearance(state.appearance, version);
|
||||||
|
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
|
||||||
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
|
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
|
||||||
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
|
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
|
||||||
nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount);
|
nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount);
|
||||||
@@ -560,9 +595,7 @@ export const useStore = create<AppState>()(
|
|||||||
return nextState as AppState;
|
return nextState as AppState;
|
||||||
},
|
},
|
||||||
merge: (persistedState, currentState) => {
|
merge: (persistedState, currentState) => {
|
||||||
const state = (persistedState && typeof persistedState === 'object')
|
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
|
||||||
? persistedState as Partial<AppState>
|
|
||||||
: {};
|
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
...state,
|
...state,
|
||||||
@@ -570,6 +603,7 @@ export const useStore = create<AppState>()(
|
|||||||
savedQueries: sanitizeSavedQueries(state.savedQueries),
|
savedQueries: sanitizeSavedQueries(state.savedQueries),
|
||||||
theme: sanitizeTheme(state.theme),
|
theme: sanitizeTheme(state.theme),
|
||||||
appearance: sanitizeAppearance(state.appearance, 3),
|
appearance: sanitizeAppearance(state.appearance, 3),
|
||||||
|
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
|
||||||
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
|
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
|
||||||
queryOptions: sanitizeQueryOptions(state.queryOptions),
|
queryOptions: sanitizeQueryOptions(state.queryOptions),
|
||||||
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
|
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
|
||||||
@@ -581,6 +615,7 @@ export const useStore = create<AppState>()(
|
|||||||
savedQueries: state.savedQueries,
|
savedQueries: state.savedQueries,
|
||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
appearance: state.appearance,
|
appearance: state.appearance,
|
||||||
|
startupFullscreen: state.startupFullscreen,
|
||||||
sqlFormatOptions: state.sqlFormatOptions,
|
sqlFormatOptions: state.sqlFormatOptions,
|
||||||
queryOptions: state.queryOptions,
|
queryOptions: state.queryOptions,
|
||||||
tableAccessCount: state.tableAccessCount,
|
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 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 CheckForUpdates():Promise<connection.QueryResult>;
|
||||||
|
|
||||||
export function ConfigureDriverRuntimeDirectory(arg1:string):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);
|
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CheckDriverNetworkStatus() {
|
||||||
|
return window['go']['app']['App']['CheckDriverNetworkStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function CheckForUpdates() {
|
export function CheckForUpdates() {
|
||||||
return window['go']['app']['App']['CheckForUpdates']();
|
return window['go']['app']['App']['CheckForUpdates']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"GoNavi-Wails/internal/connection"
|
"GoNavi-Wails/internal/connection"
|
||||||
"GoNavi-Wails/internal/db"
|
"GoNavi-Wails/internal/db"
|
||||||
|
"GoNavi-Wails/internal/logger"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
"golang.org/x/mod/semver"
|
"golang.org/x/mod/semver"
|
||||||
@@ -79,6 +81,15 @@ type driverDownloadProgressPayload struct {
|
|||||||
Message string `json:"message,omitempty"`
|
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 {
|
type pinnedDriverPackage struct {
|
||||||
Version string
|
Version string
|
||||||
DownloadURL string
|
DownloadURL string
|
||||||
@@ -188,6 +199,7 @@ const (
|
|||||||
driverVersionWarmupMinInterval = 30 * time.Second
|
driverVersionWarmupMinInterval = 30 * time.Second
|
||||||
driverBundleIndexMaxSize = 1 << 20
|
driverBundleIndexMaxSize = 1 << 20
|
||||||
driverManifestMaxSize = 2 << 20
|
driverManifestMaxSize = 2 << 20
|
||||||
|
driverNetworkProbeTimeout = 4 * time.Second
|
||||||
driverChecksumPolicyStrict = "strict"
|
driverChecksumPolicyStrict = "strict"
|
||||||
driverChecksumPolicyWarn = "warn"
|
driverChecksumPolicyWarn = "warn"
|
||||||
driverChecksumPolicyOff = "off"
|
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 {
|
func (a *App) InstallLocalDriverPackage(driverType string, filePath string, downloadDir string) connection.QueryResult {
|
||||||
definition, ok := resolveDriverDefinition(driverType)
|
definition, ok := resolveDriverDefinition(driverType)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -614,28 +679,27 @@ func (a *App) InstallLocalDriverPackage(driverType string, filePath string, down
|
|||||||
}
|
}
|
||||||
db.SetExternalDriverDownloadDirectory(resolvedDir)
|
db.SetExternalDriverDownloadDirectory(resolvedDir)
|
||||||
|
|
||||||
hash := ""
|
a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, "开始安装本地驱动包")
|
||||||
if pathText := strings.TrimSpace(filePath); pathText != "" {
|
selectedVersion := resolveDriverInstallVersion(definition.PinnedVersion, "local://manual", definition)
|
||||||
if fileHash, hashErr := hashFileSHA256(pathText); hashErr == nil {
|
meta, installErr := installOptionalDriverAgentFromLocalFile(definition, filePath, resolvedDir, selectedVersion)
|
||||||
hash = fileHash
|
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, "downloading", 90, 100, "写入驱动元数据")
|
||||||
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),
|
|
||||||
}
|
|
||||||
if err := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); err != nil {
|
if err := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); err != nil {
|
||||||
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, err.Error())
|
errText := normalizeErrorMessage(err)
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
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{}{
|
return connection.QueryResult{Success: true, Message: "驱动安装成功", Data: map[string]interface{}{
|
||||||
"driverType": definition.Type,
|
"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))
|
a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, fmt.Sprintf("开始安装 %s 驱动代理", displayName))
|
||||||
meta, installErr := installOptionalDriverAgentPackage(a, definition, selectedVersion, resolvedDir, urlText)
|
meta, installErr := installOptionalDriverAgentPackage(a, definition, selectedVersion, resolvedDir, urlText)
|
||||||
if installErr != nil {
|
if installErr != nil {
|
||||||
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, installErr.Error())
|
errText := normalizeErrorMessage(installErr)
|
||||||
return connection.QueryResult{Success: false, Message: installErr.Error()}
|
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, "写入驱动元数据")
|
a.emitDriverDownloadProgress(definition.Type, "downloading", 95, 100, "写入驱动元数据")
|
||||||
if writeErr := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); writeErr != nil {
|
if writeErr := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); writeErr != nil {
|
||||||
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, writeErr.Error())
|
errText := normalizeErrorMessage(writeErr)
|
||||||
return connection.QueryResult{Success: false, Message: writeErr.Error()}
|
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))
|
a.emitDriverDownloadProgress(definition.Type, "done", 100, 100, fmt.Sprintf("%s 驱动代理安装完成", displayName))
|
||||||
return connection.QueryResult{Success: true, Message: "驱动安装成功", Data: map[string]interface{}{
|
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),
|
DownloadedAt: time.Now().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
if err := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); err != nil {
|
if err := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); err != nil {
|
||||||
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, err.Error())
|
errText := normalizeErrorMessage(err)
|
||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
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 驱动已启用)")
|
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)
|
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 {
|
func defaultDriverDownloadDirectory() string {
|
||||||
root, err := db.ResolveExternalDriverRoot("")
|
root, err := db.ResolveExternalDriverRoot("")
|
||||||
if err == nil && strings.TrimSpace(root) != "" {
|
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))
|
endpoint := fmt.Sprintf("https://proxy.golang.org/%s/@v/list", escapeGoModulePathForProxy(trimmed))
|
||||||
client := &http.Client{
|
client := &http.Client{Timeout: driverModuleLatestProbeTimeout}
|
||||||
Timeout: driverModuleLatestProbeTimeout,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
Proxy: nil,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -2025,6 +2190,141 @@ func installOptionalDriverAgentPackage(a *App, definition driverDefinition, sele
|
|||||||
}, nil
|
}, 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) {
|
func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, executablePath string, downloadURL string) (string, string, error) {
|
||||||
driverType := normalizeDriverType(definition.Type)
|
driverType := normalizeDriverType(definition.Type)
|
||||||
displayName := resolveDriverDisplayName(definition)
|
displayName := resolveDriverDisplayName(definition)
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ type RedisClientImpl struct {
|
|||||||
forwarder *ssh.LocalForwarder
|
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
|
// NewRedisClient creates a new Redis client instance
|
||||||
func NewRedisClient() RedisClient {
|
func NewRedisClient() RedisClient {
|
||||||
return &RedisClientImpl{}
|
return &RedisClientImpl{}
|
||||||
@@ -108,21 +116,70 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
|
|||||||
if pattern == "" {
|
if pattern == "" {
|
||||||
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 {
|
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()
|
pipe := r.client.Pipeline()
|
||||||
typeResults := make([]*redis.StatusCmd, len(keys))
|
typeResults := make([]*redis.StatusCmd, len(keys))
|
||||||
ttlResults := make([]*redis.DurationCmd, 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)
|
ttlResults[i] = pipe.TTL(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = pipe.Exec(ctx)
|
_, err := pipe.Exec(ctx)
|
||||||
if err != nil && err != redis.Nil {
|
if err != nil && err != redis.Nil {
|
||||||
// Fallback: get info one by one
|
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
keyType, _ := r.GetKeyType(key)
|
keyType, typeErr := r.client.Type(ctx, key).Result()
|
||||||
ttl, _ := r.GetTTL(key)
|
if typeErr != nil && typeErr != redis.Nil {
|
||||||
result.Keys = append(result.Keys, RedisKeyInfo{
|
keyType = ""
|
||||||
|
}
|
||||||
|
ttlValue, ttlErr := r.client.TTL(ctx, key).Result()
|
||||||
|
if ttlErr != nil && ttlErr != redis.Nil {
|
||||||
|
ttlValue = -2
|
||||||
|
}
|
||||||
|
result = append(result, RedisKeyInfo{
|
||||||
Key: key,
|
Key: key,
|
||||||
Type: keyType,
|
Type: keyType,
|
||||||
TTL: ttl,
|
TTL: toRedisTTLSeconds(ttlValue),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result, nil
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, key := range keys {
|
for i, key := range keys {
|
||||||
keyType := typeResults[i].Val()
|
result = append(result, RedisKeyInfo{
|
||||||
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{
|
|
||||||
Key: key,
|
Key: key,
|
||||||
Type: keyType,
|
Type: typeResults[i].Val(),
|
||||||
TTL: ttl,
|
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
|
// GetKeyType returns the type of a key
|
||||||
|
|||||||
1
main.go
1
main.go
@@ -41,6 +41,7 @@ func main() {
|
|||||||
BackdropType: windows.Acrylic,
|
BackdropType: windows.Acrylic,
|
||||||
DisableWindowIcon: false,
|
DisableWindowIcon: false,
|
||||||
DisableFramelessWindowDecorations: false,
|
DisableFramelessWindowDecorations: false,
|
||||||
|
WebviewUserDataPath: resolveWindowsWebviewUserDataPath(),
|
||||||
},
|
},
|
||||||
Mac: &mac.Options{
|
Mac: &mac.Options{
|
||||||
WebviewIsTransparent: true,
|
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