mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 12:19:47 +08:00
Compare commits
29 Commits
fix/table-
...
v0.4.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d676ac9084 | ||
|
|
7fcbcb2471 | ||
|
|
c680e50e74 | ||
|
|
9685102229 | ||
|
|
3505b4428a | ||
|
|
9ebdf7f053 | ||
|
|
9ad852c10b | ||
|
|
2a8fff4d93 | ||
|
|
eca560b4e5 | ||
|
|
2f475dddc0 | ||
|
|
ad9d8a12be | ||
|
|
095b22951e | ||
|
|
7350a011e3 | ||
|
|
53b5802add | ||
|
|
54e7077317 | ||
|
|
4cb5071b0b | ||
|
|
96de46cf1e | ||
|
|
7d5592d8d9 | ||
|
|
d0ba8822f3 | ||
|
|
140db73ef4 | ||
|
|
7ae5341c1c | ||
|
|
01940e74b7 | ||
|
|
30210bc40e | ||
|
|
e90a3e2db6 | ||
|
|
5df95730d8 | ||
|
|
67a9c454d0 | ||
|
|
c17493952b | ||
|
|
dd258bd46c | ||
|
|
505c89066b |
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -135,11 +135,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_ARGS=()
|
||||
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||
TAG_ARGS+=(-tags "${{ matrix.wails_tags }}")
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
else
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
fi
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} "${TAG_ARGS[@]}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
|
||||
- name: Build Optional Driver Agents
|
||||
if: ${{ matrix.build_optional_agents }}
|
||||
@@ -203,7 +203,9 @@ jobs:
|
||||
APP_NAME=$(basename "$APP_PATH")
|
||||
|
||||
echo "🔏 正在进行 Ad-hoc 签名..."
|
||||
codesign --force --options runtime --deep --sign - "$APP_NAME"
|
||||
# 注意:Ad-hoc + hardened runtime(--options runtime)在未配置 entitlements 时,
|
||||
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
|
||||
codesign --force --deep --sign - "$APP_NAME"
|
||||
|
||||
DMG_NAME="${{ matrix.build_name }}.dmg"
|
||||
FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"
|
||||
|
||||
@@ -3,79 +3,79 @@
|
||||
"drivers": {
|
||||
"mariadb": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/mariadb"
|
||||
},
|
||||
"diros": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/diros"
|
||||
},
|
||||
"sphinx": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/sphinx"
|
||||
},
|
||||
"sqlserver": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.9.6",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/sqlserver"
|
||||
},
|
||||
"sqlite": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.44.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/sqlite"
|
||||
},
|
||||
"duckdb": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "2.5.5",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/duckdb"
|
||||
},
|
||||
"dameng": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.8.22",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/dameng"
|
||||
},
|
||||
"kingbase": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "0.0.0-20201021123113-29bd62a876c3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/kingbase"
|
||||
},
|
||||
"highgo": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "0.0.0-local",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/highgo"
|
||||
},
|
||||
"vastbase": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.11.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/vastbase"
|
||||
},
|
||||
"mongodb": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "2.5.0",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/mongodb"
|
||||
},
|
||||
"tdengine": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "3.7.8",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/tdengine"
|
||||
},
|
||||
"postgres": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.11.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/postgres"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress } from 'antd';
|
||||
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { Environment, EventsOn } from '../wailsjs/runtime/runtime';
|
||||
import { Environment, EventsOn, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise } from '../wailsjs/runtime/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
@@ -26,6 +26,8 @@ function App() {
|
||||
const setTheme = useStore(state => state.setTheme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const setAppearance = useStore(state => state.setAppearance);
|
||||
const startupFullscreen = useStore(state => state.startupFullscreen);
|
||||
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
|
||||
const darkMode = themeMode === 'dark';
|
||||
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
|
||||
@@ -56,6 +58,84 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let startupWindowTimer: number | null = null;
|
||||
const maxApplyAttempts = 6;
|
||||
const applyRetryDelayMs = 400;
|
||||
const settleDelayMs = 160;
|
||||
|
||||
const checkStartupPreferenceApplied = async (): Promise<boolean> => {
|
||||
try {
|
||||
if (await WindowIsFullscreen()) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (await WindowIsMaximised()) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const applyStartupWindowPreference = (attempt: number) => {
|
||||
if (startupWindowTimer !== null) {
|
||||
window.clearTimeout(startupWindowTimer);
|
||||
}
|
||||
startupWindowTimer = window.setTimeout(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (!useStore.getState().startupFullscreen) {
|
||||
return;
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
if (await checkStartupPreferenceApplied()) {
|
||||
return;
|
||||
}
|
||||
// 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。
|
||||
WindowFullscreen();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
|
||||
if (await checkStartupPreferenceApplied()) {
|
||||
return;
|
||||
}
|
||||
WindowMaximise();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
|
||||
if (await checkStartupPreferenceApplied()) {
|
||||
return;
|
||||
}
|
||||
if (attempt < maxApplyAttempts) {
|
||||
applyStartupWindowPreference(attempt + 1);
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
|
||||
if (useStore.persist.hasHydrated()) {
|
||||
applyStartupWindowPreference(1);
|
||||
}
|
||||
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
applyStartupWindowPreference(1);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (startupWindowTimer !== null) {
|
||||
window.clearTimeout(startupWindowTimer);
|
||||
}
|
||||
unsubscribeHydration();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Background Helper
|
||||
const getBg = (darkHex: string, lightHex: string) => {
|
||||
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
|
||||
@@ -915,6 +995,16 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>启动窗口</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span>启动时全屏</span>
|
||||
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
* 修改后下次启动生效
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Se
|
||||
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
import { MongoMemberInfo, SavedConnection } from '../types';
|
||||
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
|
||||
|
||||
const { Meta } = Card;
|
||||
const { Text } = Typography;
|
||||
@@ -58,6 +58,7 @@ const ConnectionModal: React.FC<{
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [useSSH, setUseSSH] = useState(false);
|
||||
const [useProxy, setUseProxy] = useState(false);
|
||||
const [dbType, setDbType] = useState('mysql');
|
||||
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
|
||||
const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1
|
||||
@@ -655,6 +656,12 @@ const ConnectionModal: React.FC<{
|
||||
sshUser: config.ssh?.user,
|
||||
sshPassword: config.ssh?.password,
|
||||
sshKeyPath: config.ssh?.keyPath,
|
||||
useProxy: config.useProxy,
|
||||
proxyType: config.proxy?.type || 'socks5',
|
||||
proxyHost: config.proxy?.host,
|
||||
proxyPort: config.proxy?.port,
|
||||
proxyUser: config.proxy?.user,
|
||||
proxyPassword: config.proxy?.password,
|
||||
driver: config.driver,
|
||||
dsn: config.dsn,
|
||||
timeout: config.timeout || 30,
|
||||
@@ -674,6 +681,7 @@ const ConnectionModal: React.FC<{
|
||||
mongoReplicaPassword: config.mongoReplicaPassword || ''
|
||||
});
|
||||
setUseSSH(config.useSSH || false);
|
||||
setUseProxy(config.useProxy || false);
|
||||
setDbType(configType);
|
||||
// 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表
|
||||
if (configType === 'redis') {
|
||||
@@ -684,6 +692,7 @@ const ConnectionModal: React.FC<{
|
||||
setStep(1);
|
||||
form.resetFields();
|
||||
setUseSSH(false);
|
||||
setUseProxy(false);
|
||||
setDbType('mysql');
|
||||
setActiveGroup(0);
|
||||
}
|
||||
@@ -733,6 +742,7 @@ const ConnectionModal: React.FC<{
|
||||
setLoading(false);
|
||||
form.resetFields();
|
||||
setUseSSH(false);
|
||||
setUseProxy(false);
|
||||
setDbType('mysql');
|
||||
setStep(1);
|
||||
onClose();
|
||||
@@ -852,7 +862,7 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const buildConfig = async (values: any, forPersist: boolean) => {
|
||||
const buildConfig = async (values: any, forPersist: boolean): Promise<ConnectionConfig> => {
|
||||
const mergedValues = { ...values };
|
||||
const parsedUriValues = parseUriToValues(mergedValues.uri, mergedValues.type);
|
||||
const isEmptyField = (value: unknown) => (
|
||||
@@ -951,6 +961,22 @@ const ConnectionModal: React.FC<{
|
||||
password: mergedValues.sshPassword || "",
|
||||
keyPath: mergedValues.sshKeyPath || ""
|
||||
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
|
||||
const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy;
|
||||
const proxyTypeRaw = String(mergedValues.proxyType || 'socks5').toLowerCase();
|
||||
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
|
||||
const proxyConfig: NonNullable<ConnectionConfig['proxy']> = effectiveUseProxy ? {
|
||||
type: proxyType,
|
||||
host: String(mergedValues.proxyHost || '').trim(),
|
||||
port: Number(mergedValues.proxyPort || (proxyTypeRaw === 'http' ? 8080 : 1080)),
|
||||
user: String(mergedValues.proxyUser || '').trim(),
|
||||
password: mergedValues.proxyPassword || "",
|
||||
} : {
|
||||
type: 'socks5',
|
||||
host: '',
|
||||
port: 1080,
|
||||
user: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
const keepPassword = !forPersist || savePassword;
|
||||
|
||||
@@ -964,6 +990,8 @@ const ConnectionModal: React.FC<{
|
||||
database: mergedValues.database || "",
|
||||
useSSH: !!mergedValues.useSSH,
|
||||
ssh: sshConfig,
|
||||
useProxy: effectiveUseProxy,
|
||||
proxy: proxyConfig,
|
||||
driver: mergedValues.driver,
|
||||
dsn: mergedValues.dsn,
|
||||
timeout: Number(mergedValues.timeout || 30),
|
||||
@@ -997,6 +1025,7 @@ const ConnectionModal: React.FC<{
|
||||
const defaultPort = getDefaultPortByType(type);
|
||||
if (isFileDatabaseType(type)) {
|
||||
setUseSSH(false);
|
||||
setUseProxy(false);
|
||||
form.setFieldsValue({
|
||||
host: '',
|
||||
port: 0,
|
||||
@@ -1009,6 +1038,12 @@ const ConnectionModal: React.FC<{
|
||||
sshUser: '',
|
||||
sshPassword: '',
|
||||
sshKeyPath: '',
|
||||
useProxy: false,
|
||||
proxyType: 'socks5',
|
||||
proxyHost: '',
|
||||
proxyPort: 1080,
|
||||
proxyUser: '',
|
||||
proxyPassword: '',
|
||||
mysqlTopology: 'single',
|
||||
mongoTopology: 'single',
|
||||
mongoSrv: false,
|
||||
@@ -1026,6 +1061,7 @@ const ConnectionModal: React.FC<{
|
||||
});
|
||||
} else if (type !== 'custom') {
|
||||
form.setFieldsValue({
|
||||
database: '',
|
||||
port: defaultPort,
|
||||
mysqlTopology: 'single',
|
||||
mongoTopology: 'single',
|
||||
@@ -1164,9 +1200,13 @@ const ConnectionModal: React.FC<{
|
||||
type: 'mysql',
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
database: '',
|
||||
user: 'root',
|
||||
useSSH: false,
|
||||
sshPort: 22,
|
||||
useProxy: false,
|
||||
proxyType: 'socks5',
|
||||
proxyPort: 1080,
|
||||
timeout: 30,
|
||||
uri: '',
|
||||
mysqlTopology: 'single',
|
||||
@@ -1191,6 +1231,21 @@ const ConnectionModal: React.FC<{
|
||||
setUriFeedback(null);
|
||||
}
|
||||
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
|
||||
if (changed.useProxy !== undefined) setUseProxy(changed.useProxy);
|
||||
if (changed.proxyType !== undefined) {
|
||||
const nextType = String(changed.proxyType || 'socks5').toLowerCase();
|
||||
if (nextType === 'http') {
|
||||
const currentPort = Number(form.getFieldValue('proxyPort') || 0);
|
||||
if (!currentPort || currentPort === 1080) {
|
||||
form.setFieldValue('proxyPort', 8080);
|
||||
}
|
||||
} else {
|
||||
const currentPort = Number(form.getFieldValue('proxyPort') || 0);
|
||||
if (!currentPort || currentPort === 8080) {
|
||||
form.setFieldValue('proxyPort', 1080);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Type change handled by step 1, but keep sync if select changes (hidden now)
|
||||
if (changed.type !== undefined) setDbType(changed.type);
|
||||
if (
|
||||
@@ -1285,6 +1340,16 @@ const ConnectionModal: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase') && (
|
||||
<Form.Item
|
||||
name="database"
|
||||
label="默认连接数据库(可选)"
|
||||
help="留空会自动尝试 postgres、template1、与当前用户名同名数据库"
|
||||
>
|
||||
<Input placeholder="例如:appdb" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && (
|
||||
<>
|
||||
<Form.Item name="mysqlTopology" label="连接模式">
|
||||
@@ -1531,6 +1596,38 @@ const ConnectionModal: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Form.Item name="useProxy" valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
<Checkbox>使用代理 (SOCKS5 / HTTP CONNECT)</Checkbox>
|
||||
</Form.Item>
|
||||
|
||||
{useProxy && (
|
||||
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: 6, marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="proxyType" label="代理类型" rules={[{ required: useProxy, message: '请选择代理类型' }]} style={{ width: 180 }}>
|
||||
<Select options={[
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
{ value: 'http', label: 'HTTP CONNECT' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="proxyHost" label="代理主机" rules={[{ required: useProxy, message: '请输入代理主机' }]} style={{ flex: 1 }}>
|
||||
<Input placeholder="例如: 127.0.0.1 或 proxy.company.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="proxyPort" label="端口" rules={[{ required: useProxy, message: '请输入代理端口' }]} style={{ width: 120 }}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="proxyUser" label="代理用户名(可选)" style={{ flex: 1 }}>
|
||||
<Input placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
<Form.Item name="proxyPassword" label="代理密码(可选)" style={{ flex: 1 }}>
|
||||
<Input.Password placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<Collapse
|
||||
|
||||
@@ -619,6 +619,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
// 使用 ref 来优化拖拽性能,完全避免状态更新
|
||||
const cellSelectionRafRef = useRef<number | null>(null);
|
||||
const cellSelectionScrollRafRef = useRef<number | null>(null);
|
||||
const cellSelectionAutoScrollRafRef = useRef<number | null>(null);
|
||||
const cellSelectionPointerRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
// 导入预览 Modal 状态
|
||||
@@ -1102,6 +1104,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
cellSelectionPointerRef.current = null;
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
updateCellSelection(new Set());
|
||||
}, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]);
|
||||
|
||||
@@ -1111,8 +1118,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const EDGE_THRESHOLD_PX = 28;
|
||||
const MIN_SCROLL_STEP = 8;
|
||||
const MAX_SCROLL_STEP = 24;
|
||||
|
||||
const getCellInfo = (target: HTMLElement): { rowKey: string; colName: string } | null => {
|
||||
const getCellInfo = (target: HTMLElement | null): { rowKey: string; colName: string } | null => {
|
||||
if (!target) return null;
|
||||
const td = target.closest('td[data-row-key][data-col-name]') as HTMLElement;
|
||||
if (!td) return null;
|
||||
const rowKey = td.getAttribute('data-row-key');
|
||||
@@ -1121,35 +1132,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return { rowKey, colName };
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const cellInfo = getCellInfo(e.target as HTMLElement);
|
||||
if (!cellInfo) return;
|
||||
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
const currentData = displayDataRef.current;
|
||||
const nextRowIndexMap = new Map<string, number>();
|
||||
currentData.forEach((r, idx) => {
|
||||
const k = r?.[GONAVI_ROW_KEY];
|
||||
if (k === undefined) return;
|
||||
nextRowIndexMap.set(String(k), idx);
|
||||
});
|
||||
rowIndexMapRef.current = nextRowIndexMap;
|
||||
|
||||
const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1;
|
||||
const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1;
|
||||
selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex };
|
||||
currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]);
|
||||
updateCellSelection(currentSelectionRef.current);
|
||||
const getCellInfoFromPoint = (x: number, y: number): { rowKey: string; colName: string } | null => {
|
||||
const target = document.elementFromPoint(x, y) as HTMLElement | null;
|
||||
return getCellInfo(target);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current || !selectionStartRef.current) return;
|
||||
|
||||
const cellInfo = getCellInfo(e.target as HTMLElement);
|
||||
if (!cellInfo) return;
|
||||
|
||||
// 使用 RAF 节流
|
||||
const scheduleSelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => {
|
||||
if (cellSelectionRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||
}
|
||||
@@ -1188,9 +1176,124 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const stopAutoScroll = () => {
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const getScrollStep = (distanceToEdge: number): number => {
|
||||
const ratio = Math.min(1, Math.max(0, distanceToEdge / EDGE_THRESHOLD_PX));
|
||||
return Math.round(MIN_SCROLL_STEP + (MAX_SCROLL_STEP - MIN_SCROLL_STEP) * ratio);
|
||||
};
|
||||
|
||||
const autoScrollTick = () => {
|
||||
if (!isDraggingRef.current || !selectionStartRef.current) {
|
||||
stopAutoScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
const pointer = cellSelectionPointerRef.current;
|
||||
const tableBody = container.querySelector('.ant-table-body') as HTMLElement | null;
|
||||
if (!pointer || !tableBody) {
|
||||
cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = tableBody.getBoundingClientRect();
|
||||
const maxScrollTop = Math.max(0, tableBody.scrollHeight - tableBody.clientHeight);
|
||||
const maxScrollLeft = Math.max(0, tableBody.scrollWidth - tableBody.clientWidth);
|
||||
let deltaY = 0;
|
||||
let deltaX = 0;
|
||||
|
||||
if (pointer.y < rect.top + EDGE_THRESHOLD_PX && tableBody.scrollTop > 0) {
|
||||
const distance = rect.top + EDGE_THRESHOLD_PX - pointer.y;
|
||||
deltaY = -getScrollStep(distance);
|
||||
} else if (pointer.y > rect.bottom - EDGE_THRESHOLD_PX && tableBody.scrollTop < maxScrollTop) {
|
||||
const distance = pointer.y - (rect.bottom - EDGE_THRESHOLD_PX);
|
||||
deltaY = getScrollStep(distance);
|
||||
}
|
||||
|
||||
if (pointer.x < rect.left + EDGE_THRESHOLD_PX && tableBody.scrollLeft > 0) {
|
||||
const distance = rect.left + EDGE_THRESHOLD_PX - pointer.x;
|
||||
deltaX = -getScrollStep(distance);
|
||||
} else if (pointer.x > rect.right - EDGE_THRESHOLD_PX && tableBody.scrollLeft < maxScrollLeft) {
|
||||
const distance = pointer.x - (rect.right - EDGE_THRESHOLD_PX);
|
||||
deltaX = getScrollStep(distance);
|
||||
}
|
||||
|
||||
let didScroll = false;
|
||||
if (deltaY !== 0) {
|
||||
const nextTop = Math.max(0, Math.min(maxScrollTop, tableBody.scrollTop + deltaY));
|
||||
if (nextTop !== tableBody.scrollTop) {
|
||||
tableBody.scrollTop = nextTop;
|
||||
didScroll = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (deltaX !== 0) {
|
||||
const nextLeft = Math.max(0, Math.min(maxScrollLeft, tableBody.scrollLeft + deltaX));
|
||||
if (nextLeft !== tableBody.scrollLeft) {
|
||||
tableBody.scrollLeft = nextLeft;
|
||||
didScroll = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (didScroll) {
|
||||
const cellInfo = getCellInfoFromPoint(pointer.x, pointer.y);
|
||||
if (cellInfo) scheduleSelectionUpdate(cellInfo);
|
||||
}
|
||||
|
||||
cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick);
|
||||
};
|
||||
|
||||
const ensureAutoScroll = () => {
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) return;
|
||||
cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick);
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||
const cellInfo = getCellInfo(target);
|
||||
if (!cellInfo) return;
|
||||
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY };
|
||||
const currentData = displayDataRef.current;
|
||||
const nextRowIndexMap = new Map<string, number>();
|
||||
currentData.forEach((r, idx) => {
|
||||
const k = r?.[GONAVI_ROW_KEY];
|
||||
if (k === undefined) return;
|
||||
nextRowIndexMap.set(String(k), idx);
|
||||
});
|
||||
rowIndexMapRef.current = nextRowIndexMap;
|
||||
|
||||
const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1;
|
||||
const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1;
|
||||
selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex };
|
||||
currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]);
|
||||
updateCellSelection(currentSelectionRef.current);
|
||||
ensureAutoScroll();
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current || !selectionStartRef.current) return;
|
||||
cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY };
|
||||
ensureAutoScroll();
|
||||
|
||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||
const cellInfo = getCellInfo(target) || getCellInfoFromPoint(e.clientX, e.clientY);
|
||||
if (!cellInfo) return;
|
||||
scheduleSelectionUpdate(cellInfo);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (!isDraggingRef.current) return;
|
||||
isDraggingRef.current = false;
|
||||
cellSelectionPointerRef.current = null;
|
||||
stopAutoScroll();
|
||||
|
||||
if (cellSelectionRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||
@@ -1231,6 +1334,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
||||
cellSelectionScrollRafRef.current = null;
|
||||
}
|
||||
stopAutoScroll();
|
||||
cellSelectionPointerRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
}, [cellEditMode, columnNames, columnIndexMap, updateCellSelection]);
|
||||
@@ -2332,6 +2437,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
cellSelectionPointerRef.current = null;
|
||||
if (cellSelectionRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||
cellSelectionRafRef.current = null;
|
||||
@@ -2340,6 +2446,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
||||
cellSelectionScrollRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
updateCellSelection(new Set());
|
||||
if (!next) setBatchEditModalOpen(false);
|
||||
message.info(next ? '已进入单元格编辑模式,可拖拽选择多个单元格' : '已退出单元格编辑模式');
|
||||
@@ -2403,12 +2513,26 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
onChange={(val) => {
|
||||
const nextMode = String(val) as GridViewMode;
|
||||
if (nextMode === 'json' && cellEditMode) {
|
||||
setCellEditMode(false);
|
||||
setSelectedCells(new Set());
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
updateCellSelection(new Set());
|
||||
}
|
||||
setCellEditMode(false);
|
||||
setSelectedCells(new Set());
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
cellSelectionPointerRef.current = null;
|
||||
if (cellSelectionRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||
cellSelectionRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
||||
cellSelectionScrollRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
updateCellSelection(new Set());
|
||||
}
|
||||
if (nextMode === 'text') {
|
||||
const selectedKey = selectedRowKeys[0];
|
||||
if (selectedKey !== undefined) {
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Modal, Progress, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Collapse, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import {
|
||||
CheckDriverNetworkStatus,
|
||||
DownloadDriverPackage,
|
||||
GetDriverVersionList,
|
||||
GetDriverVersionPackageSize,
|
||||
GetDriverStatusList,
|
||||
InstallLocalDriverPackage,
|
||||
RemoveDriverPackage,
|
||||
SelectDriverPackageFile,
|
||||
} from '../../wailsjs/go/app/App';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
type DriverStatusRow = {
|
||||
type: string;
|
||||
name: string;
|
||||
builtIn: boolean;
|
||||
pinnedVersion?: string;
|
||||
installedVersion?: string;
|
||||
packageSizeText?: string;
|
||||
runtimeAvailable: boolean;
|
||||
packageInstalled: boolean;
|
||||
connectable: boolean;
|
||||
defaultDownloadUrl?: string;
|
||||
installDir?: string;
|
||||
packagePath?: string;
|
||||
executablePath?: string;
|
||||
downloadedAt?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
@@ -35,12 +46,152 @@ type ProgressState = {
|
||||
percent: number;
|
||||
};
|
||||
|
||||
type DriverLogEntry = {
|
||||
time: string;
|
||||
text: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
type DriverNetworkProbe = {
|
||||
name: string;
|
||||
url: string;
|
||||
reachable: boolean;
|
||||
httpStatus?: number;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type DriverNetworkStatus = {
|
||||
reachable: boolean;
|
||||
summary: string;
|
||||
recommendedProxy: boolean;
|
||||
proxyConfigured: boolean;
|
||||
proxyEnv?: Record<string, string>;
|
||||
checks: DriverNetworkProbe[];
|
||||
checkedAt?: string;
|
||||
logPath?: string;
|
||||
};
|
||||
|
||||
type DriverVersionOption = {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
packageSizeText?: string;
|
||||
recommended?: boolean;
|
||||
source?: string;
|
||||
year?: string;
|
||||
displayLabel?: string;
|
||||
};
|
||||
|
||||
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
|
||||
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
|
||||
|
||||
const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
|
||||
type SelectOption = { value: string; label: string };
|
||||
type SelectGroup = { label: string; options: SelectOption[] };
|
||||
|
||||
if (options.length === 0) {
|
||||
return [] as Array<SelectOption | SelectGroup>;
|
||||
}
|
||||
|
||||
const yearGroups = new Map<string, SelectOption[]>();
|
||||
const others: SelectOption[] = [];
|
||||
options.forEach((option) => {
|
||||
const selectOption: SelectOption = {
|
||||
value: buildVersionOptionKey(option),
|
||||
label: option.displayLabel || option.version || '默认版本',
|
||||
};
|
||||
const year = String(option.year || '').trim();
|
||||
if (!year) {
|
||||
others.push(selectOption);
|
||||
return;
|
||||
}
|
||||
const group = yearGroups.get(year) || [];
|
||||
group.push(selectOption);
|
||||
yearGroups.set(year, group);
|
||||
});
|
||||
|
||||
const sortedYears = Array.from(yearGroups.keys()).sort((a, b) => {
|
||||
const left = Number.parseInt(a, 10);
|
||||
const right = Number.parseInt(b, 10);
|
||||
const leftValid = Number.isFinite(left);
|
||||
const rightValid = Number.isFinite(right);
|
||||
if (leftValid && rightValid) {
|
||||
return right - left;
|
||||
}
|
||||
return b.localeCompare(a);
|
||||
});
|
||||
|
||||
const grouped: SelectGroup[] = sortedYears.map((year) => ({
|
||||
label: `${year} 年`,
|
||||
options: yearGroups.get(year) || [],
|
||||
}));
|
||||
if (others.length > 0) {
|
||||
grouped.push({ label: '其他', options: others });
|
||||
}
|
||||
return grouped;
|
||||
};
|
||||
|
||||
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloadDir, setDownloadDir] = useState('');
|
||||
const [networkChecking, setNetworkChecking] = useState(false);
|
||||
const [networkStatus, setNetworkStatus] = useState<DriverNetworkStatus | null>(null);
|
||||
const [rows, setRows] = useState<DriverStatusRow[]>([]);
|
||||
const [actionDriver, setActionDriver] = useState('');
|
||||
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
|
||||
const [operationLogMap, setOperationLogMap] = useState<Record<string, DriverLogEntry[]>>({});
|
||||
const [logDriverType, setLogDriverType] = useState('');
|
||||
const [logModalOpen, setLogModalOpen] = useState(false);
|
||||
const [versionMap, setVersionMap] = useState<Record<string, DriverVersionOption[]>>({});
|
||||
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
|
||||
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
|
||||
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
const appendOperationLog = useCallback((
|
||||
driverType: string,
|
||||
text: string,
|
||||
signature?: string,
|
||||
mode: 'append' | 'update-last' = 'append',
|
||||
) => {
|
||||
const normalized = String(driverType || '').trim().toLowerCase();
|
||||
const content = String(text || '').trim();
|
||||
if (!normalized || !content) {
|
||||
return;
|
||||
}
|
||||
const sign = String(signature || content).trim() || content;
|
||||
const now = new Date().toLocaleTimeString();
|
||||
setOperationLogMap((prev) => {
|
||||
const history = prev[normalized] || [];
|
||||
if (history.length > 0) {
|
||||
const last = history[history.length - 1];
|
||||
if (last.signature === sign) {
|
||||
if (mode === 'update-last') {
|
||||
if (last.text === content) {
|
||||
return prev;
|
||||
}
|
||||
const nextHistory = [...history];
|
||||
nextHistory[nextHistory.length - 1] = {
|
||||
...last,
|
||||
text: content,
|
||||
time: now,
|
||||
};
|
||||
return { ...prev, [normalized]: nextHistory };
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
const nextHistory = [
|
||||
...history,
|
||||
{
|
||||
time: now,
|
||||
text: content,
|
||||
signature: sign,
|
||||
},
|
||||
];
|
||||
const sliced = nextHistory.length > 200 ? nextHistory.slice(nextHistory.length - 200) : nextHistory;
|
||||
return { ...prev, [normalized]: sliced };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async (toastOnError = true) => {
|
||||
setLoading(true);
|
||||
@@ -65,11 +216,17 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
type: String(item.type || '').trim(),
|
||||
name: String(item.name || item.type || '').trim(),
|
||||
builtIn: !!item.builtIn,
|
||||
pinnedVersion: String(item.pinnedVersion || '').trim() || undefined,
|
||||
installedVersion: String(item.installedVersion || '').trim() || undefined,
|
||||
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
|
||||
runtimeAvailable: !!item.runtimeAvailable,
|
||||
packageInstalled: !!item.packageInstalled,
|
||||
connectable: !!item.connectable,
|
||||
defaultDownloadUrl: String(item.defaultDownloadUrl || '').trim() || undefined,
|
||||
installDir: String(item.installDir || '').trim() || undefined,
|
||||
packagePath: String(item.packagePath || '').trim() || undefined,
|
||||
executablePath: String(item.executablePath || '').trim() || undefined,
|
||||
downloadedAt: String(item.downloadedAt || '').trim() || undefined,
|
||||
message: String(item.message || '').trim() || undefined,
|
||||
}));
|
||||
setRows(nextRows);
|
||||
@@ -82,12 +239,201 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
}
|
||||
}, [downloadDir]);
|
||||
|
||||
const checkNetworkStatus = useCallback(async (toastOnError = false) => {
|
||||
setNetworkChecking(true);
|
||||
try {
|
||||
const res = await CheckDriverNetworkStatus();
|
||||
if (!res?.success) {
|
||||
if (toastOnError) {
|
||||
message.error(res?.message || '驱动网络检测失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
const checks = Array.isArray(data.checks) ? data.checks : [];
|
||||
const normalizedChecks: DriverNetworkProbe[] = checks.map((item: any) => ({
|
||||
name: String(item.name || '').trim(),
|
||||
url: String(item.url || '').trim(),
|
||||
reachable: !!item.reachable,
|
||||
httpStatus: Number(item.httpStatus || 0) || undefined,
|
||||
latencyMs: Number(item.latencyMs || 0) || undefined,
|
||||
error: String(item.error || '').trim() || undefined,
|
||||
}));
|
||||
setNetworkStatus({
|
||||
reachable: !!data.reachable,
|
||||
summary: String(data.summary || '').trim() || '驱动网络检测已完成',
|
||||
recommendedProxy: !!data.recommendedProxy,
|
||||
proxyConfigured: !!data.proxyConfigured,
|
||||
proxyEnv: (data.proxyEnv || {}) as Record<string, string>,
|
||||
checkedAt: String(data.checkedAt || '').trim() || undefined,
|
||||
checks: normalizedChecks,
|
||||
logPath: String(data.logPath || '').trim() || undefined,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (toastOnError) {
|
||||
message.error(`驱动网络检测失败:${err?.message || String(err)}`);
|
||||
}
|
||||
} finally {
|
||||
setNetworkChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadVersionOptions = useCallback(async (row: DriverStatusRow, toastOnError = false) => {
|
||||
if (row.builtIn) {
|
||||
return [] as DriverVersionOption[];
|
||||
}
|
||||
const driverType = String(row.type || '').trim();
|
||||
if (!driverType) {
|
||||
return [] as DriverVersionOption[];
|
||||
}
|
||||
setVersionLoadingMap((prev) => ({ ...prev, [driverType]: true }));
|
||||
try {
|
||||
const res = await GetDriverVersionList(driverType, '');
|
||||
if (!res?.success) {
|
||||
if (toastOnError) {
|
||||
message.error(res?.message || `${row.name} 版本列表加载失败`);
|
||||
}
|
||||
return [] as DriverVersionOption[];
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
const rawVersions = Array.isArray(data.versions) ? data.versions : [];
|
||||
const options: DriverVersionOption[] = rawVersions
|
||||
.map((item: any) => {
|
||||
const version = String(item.version || '').trim();
|
||||
const downloadUrl = String(item.downloadUrl || '').trim();
|
||||
if (!version && !downloadUrl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version,
|
||||
downloadUrl,
|
||||
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
|
||||
recommended: !!item.recommended,
|
||||
source: String(item.source || '').trim() || undefined,
|
||||
year: String(item.year || '').trim() || undefined,
|
||||
displayLabel: String(item.displayLabel || '').trim() || undefined,
|
||||
} as DriverVersionOption;
|
||||
})
|
||||
.filter((item: DriverVersionOption | null): item is DriverVersionOption => !!item);
|
||||
|
||||
if (options.length === 0) {
|
||||
const fallbackVersion = String(row.pinnedVersion || '').trim();
|
||||
const fallbackURL = String(row.defaultDownloadUrl || '').trim();
|
||||
if (fallbackVersion || fallbackURL) {
|
||||
options.push({
|
||||
version: fallbackVersion,
|
||||
downloadUrl: fallbackURL,
|
||||
recommended: true,
|
||||
source: 'fallback',
|
||||
displayLabel: fallbackVersion || '默认版本',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setVersionMap((prev) => ({ ...prev, [driverType]: options }));
|
||||
setSelectedVersionMap((prev) => {
|
||||
const currentKey = prev[driverType];
|
||||
if (currentKey && options.some((option) => buildVersionOptionKey(option) === currentKey)) {
|
||||
return prev;
|
||||
}
|
||||
const preferred =
|
||||
options.find((option) => option.version === row.installedVersion) ||
|
||||
options.find((option) => option.version === row.pinnedVersion) ||
|
||||
options.find((option) => option.recommended) ||
|
||||
options[0];
|
||||
if (!preferred) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [driverType]: buildVersionOptionKey(preferred) };
|
||||
});
|
||||
return options;
|
||||
} catch (err: any) {
|
||||
if (toastOnError) {
|
||||
message.error(`加载 ${row.name} 版本列表失败:${err?.message || String(err)}`);
|
||||
}
|
||||
return [] as DriverVersionOption[];
|
||||
} finally {
|
||||
setVersionLoadingMap((prev) => ({ ...prev, [driverType]: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadVersionPackageSize = useCallback(async (row: DriverStatusRow, optionKey: string) => {
|
||||
if (row.builtIn) {
|
||||
return;
|
||||
}
|
||||
const driverType = String(row.type || '').trim();
|
||||
if (!driverType || !optionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = versionMap[driverType] || [];
|
||||
const selectedOption = options.find((item) => buildVersionOptionKey(item) === optionKey);
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
}
|
||||
if (String(selectedOption.packageSizeText || '').trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const versionText = String(selectedOption.version || '').trim();
|
||||
if (!versionText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingKey = buildVersionSizeLoadingKey(driverType, optionKey);
|
||||
if (versionSizeLoadingMap[loadingKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVersionSizeLoadingMap((prev) => ({ ...prev, [loadingKey]: true }));
|
||||
try {
|
||||
const res = await GetDriverVersionPackageSize(driverType, versionText);
|
||||
if (!res?.success) {
|
||||
return;
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
const sizeText = String(data.packageSizeText || '').trim();
|
||||
if (!sizeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVersionMap((prev) => {
|
||||
const current = prev[driverType] || [];
|
||||
let changed = false;
|
||||
const next = current.map((item) => {
|
||||
if (buildVersionOptionKey(item) !== optionKey) {
|
||||
return item;
|
||||
}
|
||||
if (String(item.packageSizeText || '').trim() === sizeText) {
|
||||
return item;
|
||||
}
|
||||
changed = true;
|
||||
return { ...item, packageSizeText: sizeText };
|
||||
});
|
||||
if (!changed) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [driverType]: next };
|
||||
});
|
||||
} finally {
|
||||
setVersionSizeLoadingMap((prev) => {
|
||||
if (!prev[loadingKey]) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[loadingKey];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [versionMap, versionSizeLoadingMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
refreshStatus(false);
|
||||
}, [open, refreshStatus]);
|
||||
checkNetworkStatus(false);
|
||||
}, [checkNetworkStatus, open, refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -112,11 +458,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
percent,
|
||||
},
|
||||
}));
|
||||
const progressText = `${Math.round(percent)}%`;
|
||||
const statusText = String(status || '').toUpperCase();
|
||||
const lineText = `[${statusText}] ${messageText || '-'} (${progressText})`;
|
||||
const lineSignature = `${statusText}|${messageText || '-'}`;
|
||||
appendOperationLog(driverType, lineText, lineSignature, 'update-last');
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
};
|
||||
}, [open]);
|
||||
}, [appendOperationLog, open]);
|
||||
|
||||
const installDriver = useCallback(async (row: DriverStatusRow) => {
|
||||
setActionDriver(row.type);
|
||||
@@ -128,27 +479,97 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
percent: 0,
|
||||
},
|
||||
}));
|
||||
appendOperationLog(row.type, '[START] 开始自动安装');
|
||||
try {
|
||||
const result = await DownloadDriverPackage(row.type, '', downloadDir);
|
||||
let options = versionMap[row.type] || [];
|
||||
if (options.length === 0) {
|
||||
options = await loadVersionOptions(row, true);
|
||||
}
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const selectedOption =
|
||||
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
|
||||
options.find((item) => item.recommended) ||
|
||||
options[0];
|
||||
const selectedVersion = selectedOption?.version || row.pinnedVersion || '';
|
||||
const selectedDownloadURL = selectedOption?.downloadUrl || row.defaultDownloadUrl || '';
|
||||
|
||||
const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir);
|
||||
if (!result?.success) {
|
||||
message.error(result?.message || `安装 ${row.name} 失败`);
|
||||
const errText = result?.message || `安装 ${row.name} 失败`;
|
||||
appendOperationLog(row.type, `[ERROR] ${errText}`);
|
||||
message.error(errText);
|
||||
return;
|
||||
}
|
||||
message.success(`${row.name} 已安装启用`);
|
||||
const versionTip = selectedVersion ? `(${selectedVersion})` : '';
|
||||
appendOperationLog(row.type, `[DONE] 自动安装完成 ${versionTip}`);
|
||||
message.success(`${row.name}${versionTip} 已安装启用`);
|
||||
refreshStatus(false);
|
||||
} finally {
|
||||
setActionDriver('');
|
||||
}
|
||||
}, [downloadDir, refreshStatus]);
|
||||
}, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]);
|
||||
|
||||
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
|
||||
const fileRes = await SelectDriverPackageFile(downloadDir);
|
||||
if (!fileRes?.success) {
|
||||
if (String(fileRes?.message || '') !== 'Cancelled') {
|
||||
message.error(fileRes?.message || '选择本地驱动包失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const filePath = String((fileRes?.data as any)?.path || '').trim();
|
||||
if (!filePath) {
|
||||
message.error('未选择有效的驱动包文件');
|
||||
return;
|
||||
}
|
||||
|
||||
setActionDriver(row.type);
|
||||
setProgressMap((prev) => ({
|
||||
...prev,
|
||||
[row.type]: {
|
||||
status: 'start',
|
||||
message: '开始导入本地驱动包',
|
||||
percent: 0,
|
||||
},
|
||||
}));
|
||||
appendOperationLog(row.type, `[START] 开始本地导入:${filePath}`);
|
||||
try {
|
||||
const result = await InstallLocalDriverPackage(row.type, filePath, downloadDir);
|
||||
if (!result?.success) {
|
||||
const errText = result?.message || `导入 ${row.name} 本地驱动包失败`;
|
||||
appendOperationLog(row.type, `[ERROR] ${errText}`);
|
||||
message.error(errText);
|
||||
return;
|
||||
}
|
||||
appendOperationLog(row.type, '[DONE] 本地导入安装完成');
|
||||
message.success(`${row.name} 本地驱动包已安装启用`);
|
||||
refreshStatus(false);
|
||||
} finally {
|
||||
setActionDriver('');
|
||||
}
|
||||
}, [appendOperationLog, downloadDir, refreshStatus]);
|
||||
|
||||
const openDriverLog = useCallback((driverType: string) => {
|
||||
const normalized = String(driverType || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
setLogDriverType(normalized);
|
||||
setLogModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const removeDriver = useCallback(async (row: DriverStatusRow) => {
|
||||
setActionDriver(row.type);
|
||||
appendOperationLog(row.type, '[START] 开始移除驱动');
|
||||
try {
|
||||
const result = await RemoveDriverPackage(row.type, downloadDir);
|
||||
if (!result?.success) {
|
||||
message.error(result?.message || `移除 ${row.name} 失败`);
|
||||
const errText = result?.message || `移除 ${row.name} 失败`;
|
||||
appendOperationLog(row.type, `[ERROR] ${errText}`);
|
||||
message.error(errText);
|
||||
return;
|
||||
}
|
||||
appendOperationLog(row.type, '[DONE] 驱动移除完成');
|
||||
message.success(`${row.name} 已移除`);
|
||||
setProgressMap((prev) => {
|
||||
const next = { ...prev };
|
||||
@@ -159,7 +580,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
} finally {
|
||||
setActionDriver('');
|
||||
}
|
||||
}, [downloadDir, refreshStatus]);
|
||||
}, [appendOperationLog, downloadDir, refreshStatus]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
@@ -169,12 +590,47 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
key: 'name',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '安装位置',
|
||||
key: 'installPath',
|
||||
width: 260,
|
||||
render: (_: string, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Text type="secondary">内置</Text>;
|
||||
}
|
||||
const installPath = row.executablePath || row.installDir || '-';
|
||||
if (installPath === '-') {
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
return (
|
||||
<Text copyable={{ text: installPath }} style={{ fontSize: 12 }}>
|
||||
{installPath}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '安装包大小',
|
||||
dataIndex: 'packageSizeText',
|
||||
key: 'packageSizeText',
|
||||
width: 120,
|
||||
render: (_: string | undefined, row: DriverStatusRow) => row.packageSizeText || '-',
|
||||
render: (_: string | undefined, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return row.packageSizeText || '-';
|
||||
}
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || '');
|
||||
const selectedOption =
|
||||
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
|
||||
options.find((item) => item.recommended) ||
|
||||
options[0];
|
||||
const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText;
|
||||
if (selectedKey && versionSizeLoadingMap[loadingKey]) {
|
||||
return '计算中...';
|
||||
}
|
||||
return selectedOption?.packageSizeText || anyKnownSize || row.packageSizeText || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
@@ -224,10 +680,47 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
return <Progress percent={percent} status={status} size="small" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '驱动版本',
|
||||
key: 'driverVersion',
|
||||
width: 230,
|
||||
render: (_: string, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const selectOptions = buildVersionSelectOptions(options);
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
loading={!!versionLoadingMap[row.type]}
|
||||
disabled={actionDriver === row.type}
|
||||
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
|
||||
value={selectedKey}
|
||||
options={selectOptions as any}
|
||||
onOpenChange={(open) => {
|
||||
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
|
||||
void loadVersionOptions(row, true);
|
||||
return;
|
||||
}
|
||||
if (open && selectedKey) {
|
||||
void loadVersionPackageSize(row, selectedKey);
|
||||
}
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
|
||||
void loadVersionPackageSize(row, value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 190,
|
||||
width: 320,
|
||||
render: (_: string, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Text type="secondary">-</Text>;
|
||||
@@ -237,19 +730,20 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
if (isSlimBuildUnavailable && !row.packageInstalled) {
|
||||
return <Text type="secondary">需 Full 版</Text>;
|
||||
}
|
||||
if (row.connectable) {
|
||||
return (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={loadingAction}
|
||||
onClick={() => removeDriver(row)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
const logs = operationLogMap[row.type] || [];
|
||||
const hasLogs = logs.length > 0;
|
||||
|
||||
const mainAction = row.connectable ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={loadingAction}
|
||||
onClick={() => removeDriver(row)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
@@ -259,10 +753,41 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
安装启用
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Space size={8} wrap>
|
||||
{mainAction}
|
||||
<Button
|
||||
icon={<FileSearchOutlined />}
|
||||
loading={loadingAction}
|
||||
onClick={() => installDriverFromLocalFile(row)}
|
||||
>
|
||||
本地导入
|
||||
</Button>
|
||||
<Button
|
||||
type={hasLogs ? 'default' : 'text'}
|
||||
disabled={!hasLogs}
|
||||
onClick={() => openDriverLog(row.type)}
|
||||
>
|
||||
日志
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [actionDriver, installDriver, progressMap, removeDriver]);
|
||||
}, [actionDriver, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
|
||||
|
||||
const activeLogRow = useMemo(() => {
|
||||
if (!logDriverType) {
|
||||
return undefined;
|
||||
}
|
||||
return rows.find((item) => item.type === logDriverType);
|
||||
}, [logDriverType, rows]);
|
||||
|
||||
const activeDriverLogs = operationLogMap[logDriverType] || [];
|
||||
const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`);
|
||||
const proxyEnvEntries = Object.entries(networkStatus?.proxyEnv || {});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -270,11 +795,23 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={980}
|
||||
style={{ top: 24 }}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 18,
|
||||
},
|
||||
}}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
|
||||
刷新
|
||||
</Button>,
|
||||
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
|
||||
网络检测
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={onClose}>
|
||||
关闭
|
||||
</Button>,
|
||||
@@ -282,6 +819,67 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Text type="secondary">除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。</Text>
|
||||
{networkStatus ? (
|
||||
<Alert
|
||||
type={networkStatus.reachable ? 'success' : 'warning'}
|
||||
showIcon
|
||||
message={networkStatus.summary}
|
||||
description={(
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary">
|
||||
驱动下载依赖 GitHub 与 Go 模块代理网络。若检测失败,建议先启用 HTTP/HTTPS/SOCKS5 代理后重试。
|
||||
</Text>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'checks',
|
||||
label: '查看网络检测明细',
|
||||
children: (
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{networkStatus.checks.map((item) => (
|
||||
<Text key={`${item.name}-${item.url}`} type={item.reachable ? 'secondary' : 'danger'}>
|
||||
{item.name}:{item.reachable ? '可达' : '不可达'}{item.httpStatus ? `,HTTP ${item.httpStatus}` : ''}{item.latencyMs ? `,${item.latencyMs}ms` : ''}{item.error ? `,${item.error}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
{proxyEnvEntries.length > 0 ? (
|
||||
<Text type="secondary">
|
||||
检测到代理环境变量:{proxyEnvEntries.map(([key]) => key).join('、')}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary">未检测到系统代理环境变量。</Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="info" showIcon message={networkChecking ? '正在检测驱动下载网络...' : '尚未完成网络检测'} />
|
||||
)}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="驱动目录与复用说明"
|
||||
description={(
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary">自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。</Text>
|
||||
<Text type="secondary">手动导入支持单个驱动代理文件(如 `mariadb-driver-agent` / `mariadb-driver-agent.exe`)或驱动总包 `GoNavi-DriverAgents.zip`。</Text>
|
||||
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
|
||||
驱动根目录:{downloadDir || '-'}
|
||||
</Paragraph>
|
||||
{networkStatus?.logPath ? (
|
||||
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
|
||||
运行日志文件:{networkStatus.logPath}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Table
|
||||
rowKey="type"
|
||||
@@ -290,8 +888,40 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
scroll={{ x: 1450 }}
|
||||
/>
|
||||
</Space>
|
||||
<Modal
|
||||
title={`驱动日志 - ${activeLogRow?.name || logDriverType}`}
|
||||
open={logModalOpen}
|
||||
onCancel={() => setLogModalOpen(false)}
|
||||
footer={[
|
||||
<Button key="close-log" type="primary" onClick={() => setLogModalOpen(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={780}
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{activeLogRow?.installDir ? (
|
||||
<Paragraph copyable={{ text: activeLogRow.installDir }} style={{ marginBottom: 0 }}>
|
||||
安装目录:{activeLogRow.installDir}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
{activeLogRow?.executablePath ? (
|
||||
<Paragraph copyable={{ text: activeLogRow.executablePath }} style={{ marginBottom: 0 }}>
|
||||
驱动可执行文件:{activeLogRow.executablePath}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
{activeDriverLogLines.length > 0 ? (
|
||||
<pre style={{ margin: 0, maxHeight: 360, overflow: 'auto', padding: 12, background: '#fafafa', borderRadius: 8, border: '1px solid #f0f0f0', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{activeDriverLogLines.join('\n')}
|
||||
</pre>
|
||||
) : (
|
||||
<Text type="secondary">当前驱动暂无操作日志。</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ const REDIS_TREE_KEY_TYPE_WIDTH = 92;
|
||||
const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84;
|
||||
const REDIS_TREE_KEY_TTL_WIDTH = 92;
|
||||
const REDIS_TREE_HIDE_TTL_THRESHOLD = 460;
|
||||
const REDIS_KEY_INITIAL_LOAD_COUNT = 2000;
|
||||
const REDIS_KEY_LOAD_MORE_COUNT = 2000;
|
||||
|
||||
interface RedisViewerProps {
|
||||
connectionId: string;
|
||||
@@ -462,27 +464,34 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
}, [connection, redisDB]);
|
||||
|
||||
const loadKeys = useCallback(async (pattern: string = '*', fromCursor: number = 0, append: boolean = false) => {
|
||||
const loadKeys = useCallback(async (
|
||||
pattern: string = '*',
|
||||
fromCursor: number = 0,
|
||||
append: boolean = false,
|
||||
targetCount: number = REDIS_KEY_INITIAL_LOAD_COUNT
|
||||
) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, 100);
|
||||
const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, targetCount);
|
||||
if (res.success) {
|
||||
const result = res.data;
|
||||
const scannedKeys = Array.isArray(result?.keys) ? result.keys : [];
|
||||
const nextCursor = Number(result?.cursor || 0);
|
||||
if (append) {
|
||||
setKeys(prev => {
|
||||
const keyMap = new Map<string, RedisKeyInfo>();
|
||||
prev.forEach(item => keyMap.set(item.key, item));
|
||||
result.keys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
|
||||
scannedKeys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
|
||||
return Array.from(keyMap.values());
|
||||
});
|
||||
} else {
|
||||
setKeys(result.keys);
|
||||
setKeys(scannedKeys);
|
||||
}
|
||||
setCursor(result.cursor);
|
||||
setHasMore(result.cursor !== 0);
|
||||
setCursor(nextCursor);
|
||||
setHasMore(nextCursor !== 0);
|
||||
} else {
|
||||
message.error('加载 Key 失败: ' + res.message);
|
||||
}
|
||||
@@ -494,23 +503,26 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
}, [getConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys(searchPattern, 0, false);
|
||||
loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
|
||||
}, [redisDB]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
const pattern = value.trim() || '*';
|
||||
setSearchPattern(pattern);
|
||||
setCursor(0);
|
||||
loadKeys(pattern, 0, false);
|
||||
loadKeys(pattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
loadKeys(searchPattern, cursor, true);
|
||||
if (!hasMore || loading) {
|
||||
return;
|
||||
}
|
||||
loadKeys(searchPattern, cursor, true, REDIS_KEY_LOAD_MORE_COUNT);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setCursor(0);
|
||||
loadKeys(searchPattern, 0, false);
|
||||
loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
|
||||
};
|
||||
|
||||
const loadKeyValue = async (key: string) => {
|
||||
@@ -1777,7 +1789,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
</Spin>
|
||||
{hasMore && (
|
||||
<div style={{ padding: 8, textAlign: 'center' }}>
|
||||
<Button onClick={handleLoadMore} loading={loading}>加载更多</Button>
|
||||
<Button onClick={handleLoadMore} loading={loading} disabled={!hasMore || loading}>加载更多</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,8 @@ interface TreeNode {
|
||||
|
||||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||
type BatchObjectType = 'table' | 'view';
|
||||
type BatchObjectFilterType = 'all' | BatchObjectType;
|
||||
type BatchSelectionScope = 'filtered' | 'all';
|
||||
|
||||
interface BatchObjectItem {
|
||||
title: string;
|
||||
@@ -133,11 +135,47 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [selectedConnection, setSelectedConnection] = useState<string>('');
|
||||
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
|
||||
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
|
||||
const [batchFilterKeyword, setBatchFilterKeyword] = useState<string>('');
|
||||
const [batchFilterType, setBatchFilterType] = useState<BatchObjectFilterType>('all');
|
||||
const [batchSelectionScope, setBatchSelectionScope] = useState<BatchSelectionScope>('filtered');
|
||||
const filteredBatchObjects = useMemo(() => {
|
||||
const keyword = batchFilterKeyword.trim().toLowerCase();
|
||||
return batchTables.filter((item) => {
|
||||
if (batchFilterType !== 'all' && item.objectType !== batchFilterType) {
|
||||
return false;
|
||||
}
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
return item.title.toLowerCase().includes(keyword) || item.objectName.toLowerCase().includes(keyword);
|
||||
});
|
||||
}, [batchFilterKeyword, batchFilterType, batchTables]);
|
||||
const groupedBatchObjects = useMemo(() => {
|
||||
const tables = batchTables.filter(item => item.objectType === 'table');
|
||||
const views = batchTables.filter(item => item.objectType === 'view');
|
||||
const tables = filteredBatchObjects.filter(item => item.objectType === 'table');
|
||||
const views = filteredBatchObjects.filter(item => item.objectType === 'view');
|
||||
return { tables, views };
|
||||
}, [batchTables]);
|
||||
}, [filteredBatchObjects]);
|
||||
const allBatchObjectKeys = useMemo(() => batchTables.map(item => item.key), [batchTables]);
|
||||
const allBatchObjectKeysByType = useMemo(() => {
|
||||
if (batchFilterType === 'all') {
|
||||
return allBatchObjectKeys;
|
||||
}
|
||||
return batchTables
|
||||
.filter((item) => item.objectType === batchFilterType)
|
||||
.map((item) => item.key);
|
||||
}, [allBatchObjectKeys, batchFilterType, batchTables]);
|
||||
const filteredBatchObjectKeys = useMemo(() => filteredBatchObjects.map(item => item.key), [filteredBatchObjects]);
|
||||
const selectionScopeTargetKeys = useMemo(
|
||||
() => (batchSelectionScope === 'filtered' ? filteredBatchObjectKeys : allBatchObjectKeysByType),
|
||||
[allBatchObjectKeysByType, batchSelectionScope, filteredBatchObjectKeys]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (batchFilterType === 'all') {
|
||||
return;
|
||||
}
|
||||
const allowed = new Set(allBatchObjectKeysByType);
|
||||
setCheckedTableKeys((prev) => prev.filter((key) => allowed.has(key)));
|
||||
}, [allBatchObjectKeysByType, batchFilterType]);
|
||||
|
||||
// Batch Database Operations Modal
|
||||
const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false);
|
||||
@@ -1313,6 +1351,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setBatchTables([]);
|
||||
setCheckedTableKeys([]);
|
||||
setAvailableDatabases([]);
|
||||
setBatchFilterKeyword('');
|
||||
setBatchFilterType('all');
|
||||
setBatchSelectionScope('filtered');
|
||||
|
||||
if (connId) {
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
@@ -1413,6 +1454,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setSelectedDatabase('');
|
||||
setBatchTables([]);
|
||||
setCheckedTableKeys([]);
|
||||
setBatchFilterKeyword('');
|
||||
setBatchFilterType('all');
|
||||
setBatchSelectionScope('filtered');
|
||||
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
@@ -1422,6 +1466,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
const handleDatabaseChange = async (dbName: string) => {
|
||||
setSelectedDatabase(dbName);
|
||||
setBatchFilterKeyword('');
|
||||
setBatchFilterType('all');
|
||||
setBatchSelectionScope('filtered');
|
||||
|
||||
const conn = connections.find(c => c.id === selectedConnection);
|
||||
if (conn && dbName) {
|
||||
@@ -1470,17 +1517,44 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
|
||||
const handleCheckAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setCheckedTableKeys(batchTables.map(t => t.key));
|
||||
} else {
|
||||
setCheckedTableKeys([]);
|
||||
if (batchSelectionScope === 'all') {
|
||||
setCheckedTableKeys(checked ? allBatchObjectKeys : []);
|
||||
return;
|
||||
}
|
||||
if (filteredBatchObjectKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (checked) {
|
||||
setCheckedTableKeys(prev => {
|
||||
const nextSet = new Set(prev);
|
||||
filteredBatchObjectKeys.forEach((key) => nextSet.add(key));
|
||||
return allBatchObjectKeys.filter((key) => nextSet.has(key));
|
||||
});
|
||||
return;
|
||||
}
|
||||
const filteredKeySet = new Set(filteredBatchObjectKeys);
|
||||
setCheckedTableKeys(prev => prev.filter((key) => !filteredKeySet.has(key)));
|
||||
};
|
||||
|
||||
const handleInvertSelection = () => {
|
||||
const allKeys = batchTables.map(t => t.key);
|
||||
const newChecked = allKeys.filter(k => !checkedTableKeys.includes(k));
|
||||
setCheckedTableKeys(newChecked);
|
||||
if (batchSelectionScope === 'all') {
|
||||
setCheckedTableKeys(prev => allBatchObjectKeys.filter((key) => !prev.includes(key)));
|
||||
return;
|
||||
}
|
||||
if (filteredBatchObjectKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
setCheckedTableKeys(prev => {
|
||||
const nextSet = new Set(prev);
|
||||
filteredBatchObjectKeys.forEach((key) => {
|
||||
if (nextSet.has(key)) {
|
||||
nextSet.delete(key);
|
||||
} else {
|
||||
nextSet.add(key);
|
||||
}
|
||||
});
|
||||
return allBatchObjectKeys.filter((key) => nextSet.has(key));
|
||||
});
|
||||
};
|
||||
|
||||
const openBatchDatabaseModal = async () => {
|
||||
@@ -2874,6 +2948,43 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batchTables.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap size={8} style={{ width: '100%' }}>
|
||||
<Input
|
||||
allowClear
|
||||
value={batchFilterKeyword}
|
||||
onChange={(e) => setBatchFilterKeyword(e.target.value)}
|
||||
placeholder="筛选表/视图名称"
|
||||
prefix={<SearchOutlined />}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Select
|
||||
value={batchFilterType}
|
||||
onChange={(value) => setBatchFilterType(value as BatchObjectFilterType)}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: '全部对象', value: 'all' },
|
||||
{ label: '仅表', value: 'table' },
|
||||
{ label: '仅视图', value: 'view' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={batchSelectionScope}
|
||||
onChange={(value) => setBatchSelectionScope(value as BatchSelectionScope)}
|
||||
style={{ width: 220 }}
|
||||
options={[
|
||||
{ label: '勾选作用于:当前筛选结果', value: 'filtered' },
|
||||
{ label: '勾选作用于:全部对象', value: 'all' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ marginTop: 6, color: '#999', fontSize: 12 }}>
|
||||
当前筛选命中 {filteredBatchObjects.length} / {batchTables.length} 个对象
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchTables.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
@@ -2881,18 +2992,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCheckAll(true)}
|
||||
disabled={selectionScopeTargetKeys.length === 0}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCheckAll(false)}
|
||||
disabled={selectionScopeTargetKeys.length === 0}
|
||||
>
|
||||
取消全选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleInvertSelection}
|
||||
disabled={selectionScopeTargetKeys.length === 0}
|
||||
>
|
||||
反选
|
||||
</Button>
|
||||
@@ -2938,6 +3052,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{groupedBatchObjects.tables.length === 0 && groupedBatchObjects.views.length === 0 && (
|
||||
<div style={{ color: '#999', padding: '8px 0' }}>
|
||||
无匹配对象
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
@@ -6,21 +6,7 @@ import App from './App'
|
||||
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
|
||||
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker'
|
||||
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker'
|
||||
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js'
|
||||
import 'monaco-editor/esm/vs/language/json/monaco.contribution.js'
|
||||
|
||||
(self as any).MonacoEnvironment = {
|
||||
getWorker(_: unknown, label: string) {
|
||||
if (label === 'json') {
|
||||
return new JsonWorker()
|
||||
}
|
||||
return new EditorWorker()
|
||||
},
|
||||
}
|
||||
|
||||
import * as monaco from 'monaco-editor'
|
||||
loader.config({ monaco })
|
||||
|
||||
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
|
||||
|
||||
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
|
||||
import { ConnectionConfig, SavedConnection, TabData, SavedQuery } from './types';
|
||||
|
||||
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
|
||||
const DEFAULT_STARTUP_FULLSCREEN = false;
|
||||
const LEGACY_DEFAULT_OPACITY = 0.95;
|
||||
const OPACITY_EPSILON = 1e-6;
|
||||
const MAX_URI_LENGTH = 4096;
|
||||
@@ -155,6 +156,16 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
password: toTrimmedString(sshRaw.password),
|
||||
keyPath: toTrimmedString(sshRaw.keyPath),
|
||||
};
|
||||
const proxyRaw = (raw.proxy && typeof raw.proxy === 'object') ? raw.proxy as Record<string, unknown> : {};
|
||||
const proxyTypeRaw = toTrimmedString(proxyRaw.type, 'socks5').toLowerCase();
|
||||
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
|
||||
const proxy = {
|
||||
type: proxyType,
|
||||
host: toTrimmedString(proxyRaw.host),
|
||||
port: normalizePort(proxyRaw.port, proxyTypeRaw === 'http' ? 8080 : 1080),
|
||||
user: toTrimmedString(proxyRaw.user),
|
||||
password: toTrimmedString(proxyRaw.password),
|
||||
};
|
||||
|
||||
const safeConfig: ConnectionConfig & Record<string, unknown> = {
|
||||
...raw,
|
||||
@@ -167,6 +178,8 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
database: toTrimmedString(raw.database),
|
||||
useSSH: !!raw.useSSH,
|
||||
ssh,
|
||||
useProxy: !!raw.useProxy,
|
||||
proxy,
|
||||
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
|
||||
hosts: sanitizeAddressList(raw.hosts),
|
||||
topology: raw.topology === 'replica' ? 'replica' : 'single',
|
||||
@@ -194,10 +207,27 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
return safeConfig;
|
||||
};
|
||||
|
||||
const resolveConnectionConfigPayload = (raw: Record<string, unknown>): unknown => {
|
||||
if (raw.config && typeof raw.config === 'object') {
|
||||
return raw.config;
|
||||
}
|
||||
// 兼容历史/导入场景:连接对象可能是扁平结构(无 config 包装)。
|
||||
const hasLegacyFlatConfig =
|
||||
raw.type !== undefined ||
|
||||
raw.host !== undefined ||
|
||||
raw.port !== undefined ||
|
||||
raw.user !== undefined ||
|
||||
raw.database !== undefined;
|
||||
if (hasLegacyFlatConfig) {
|
||||
return raw;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const config = sanitizeConnectionConfig(raw.config);
|
||||
const config = sanitizeConnectionConfig(resolveConnectionConfigPayload(raw));
|
||||
const id = toTrimmedString(raw.id, `conn-${index + 1}`) || `conn-${index + 1}`;
|
||||
const fallbackName = config.host ? `${config.type}-${config.host}` : `连接-${index + 1}`;
|
||||
const name = toTrimmedString(raw.name, fallbackName) || fallbackName;
|
||||
@@ -266,6 +296,7 @@ interface AppState {
|
||||
savedQueries: SavedQuery[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { opacity: number; blur: number };
|
||||
startupFullscreen: boolean;
|
||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||
queryOptions: QueryOptions;
|
||||
sqlLogs: SqlLog[];
|
||||
@@ -292,6 +323,7 @@ interface AppState {
|
||||
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
|
||||
setStartupFullscreen: (enabled: boolean) => void;
|
||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||
setQueryOptions: (options: Partial<QueryOptions>) => void;
|
||||
|
||||
@@ -380,6 +412,21 @@ const sanitizeAppearance = (
|
||||
return nextAppearance;
|
||||
};
|
||||
|
||||
const sanitizeStartupFullscreen = (value: unknown): boolean => {
|
||||
return value === true;
|
||||
};
|
||||
|
||||
const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknown> => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return {};
|
||||
}
|
||||
const raw = persistedState as Record<string, unknown>;
|
||||
if (raw.state && typeof raw.state === 'object') {
|
||||
return raw.state as Record<string, unknown>;
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
export const useStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
@@ -390,6 +437,7 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: [],
|
||||
theme: 'light',
|
||||
appearance: { ...DEFAULT_APPEARANCE },
|
||||
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
|
||||
sqlFormatOptions: { keywordCase: 'upper' },
|
||||
queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true },
|
||||
sqlLogs: [],
|
||||
@@ -501,6 +549,7 @@ export const useStore = create<AppState>()(
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
|
||||
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
|
||||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
|
||||
|
||||
@@ -532,15 +581,13 @@ export const useStore = create<AppState>()(
|
||||
name: 'lite-db-storage', // name of the item in the storage (must be unique)
|
||||
version: 3,
|
||||
migrate: (persistedState: unknown, version: number) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState as AppState;
|
||||
}
|
||||
const state = persistedState as Partial<AppState>;
|
||||
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
|
||||
const nextState: Partial<AppState> = { ...state };
|
||||
nextState.connections = sanitizeConnections(state.connections);
|
||||
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
|
||||
nextState.theme = sanitizeTheme(state.theme);
|
||||
nextState.appearance = sanitizeAppearance(state.appearance, version);
|
||||
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
|
||||
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
|
||||
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
|
||||
nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount);
|
||||
@@ -548,9 +595,7 @@ export const useStore = create<AppState>()(
|
||||
return nextState as AppState;
|
||||
},
|
||||
merge: (persistedState, currentState) => {
|
||||
const state = (persistedState && typeof persistedState === 'object')
|
||||
? persistedState as Partial<AppState>
|
||||
: {};
|
||||
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
|
||||
return {
|
||||
...currentState,
|
||||
...state,
|
||||
@@ -558,6 +603,7 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: sanitizeSavedQueries(state.savedQueries),
|
||||
theme: sanitizeTheme(state.theme),
|
||||
appearance: sanitizeAppearance(state.appearance, 3),
|
||||
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
|
||||
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
|
||||
queryOptions: sanitizeQueryOptions(state.queryOptions),
|
||||
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
|
||||
@@ -569,6 +615,7 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: state.savedQueries,
|
||||
theme: state.theme,
|
||||
appearance: state.appearance,
|
||||
startupFullscreen: state.startupFullscreen,
|
||||
sqlFormatOptions: state.sqlFormatOptions,
|
||||
queryOptions: state.queryOptions,
|
||||
tableAccessCount: state.tableAccessCount,
|
||||
|
||||
@@ -6,6 +6,14 @@ export interface SSHConfig {
|
||||
keyPath?: string;
|
||||
}
|
||||
|
||||
export interface ProxyConfig {
|
||||
type: 'socks5' | 'http';
|
||||
host: string;
|
||||
port: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
type: string;
|
||||
host: string;
|
||||
@@ -16,6 +24,8 @@ export interface ConnectionConfig {
|
||||
database?: string;
|
||||
useSSH?: boolean;
|
||||
ssh?: SSHConfig;
|
||||
useProxy?: boolean;
|
||||
proxy?: ProxyConfig;
|
||||
driver?: string;
|
||||
dsn?: string;
|
||||
timeout?: number;
|
||||
|
||||
@@ -1,54 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const normalizeModuleId = (id: string): string => id.replace(/\\/g, '/')
|
||||
|
||||
const sanitizeChunkToken = (raw: string): string =>
|
||||
String(raw || '')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'misc'
|
||||
|
||||
const firstSegmentAfter = (id: string, marker: string): string => {
|
||||
const idx = id.indexOf(marker)
|
||||
if (idx < 0) return ''
|
||||
const rest = id.substring(idx + marker.length)
|
||||
const [segment] = rest.split('/')
|
||||
return sanitizeChunkToken(segment)
|
||||
}
|
||||
|
||||
const resolveMonacoChunk = (id: string, prefix: string): string | undefined => {
|
||||
if (!id.includes('/node_modules/monaco-editor/')) return undefined
|
||||
|
||||
if (id.includes('/esm/vs/language/typescript/')) {
|
||||
if (id.includes('typescriptServices')) return `${prefix}-ts-services`
|
||||
return `${prefix}-typescript`
|
||||
}
|
||||
if (id.includes('/esm/vs/language/json/')) return `${prefix}-json`
|
||||
if (id.includes('/esm/vs/language/css/')) return `${prefix}-css`
|
||||
if (id.includes('/esm/vs/language/html/')) return `${prefix}-html`
|
||||
|
||||
if (id.includes('/esm/vs/editor/contrib/')) {
|
||||
return `${prefix}-editor-contrib-${firstSegmentAfter(id, '/esm/vs/editor/contrib/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/browser/')) {
|
||||
return `${prefix}-editor-browser-${firstSegmentAfter(id, '/esm/vs/editor/browser/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/common/')) {
|
||||
return `${prefix}-editor-common-${firstSegmentAfter(id, '/esm/vs/editor/common/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/')) return `${prefix}-editor`
|
||||
|
||||
if (id.includes('/esm/vs/base/browser/')) return `${prefix}-base-browser`
|
||||
if (id.includes('/esm/vs/base/common/')) return `${prefix}-base-common`
|
||||
if (id.includes('/esm/vs/base/')) return `${prefix}-base`
|
||||
|
||||
if (id.includes('/esm/vs/platform/')) return `${prefix}-platform`
|
||||
|
||||
return `${prefix}-misc`
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -59,61 +11,5 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist', // Standard Wails output directory
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const moduleId = normalizeModuleId(id)
|
||||
if (!moduleId.includes('node_modules')) return undefined
|
||||
|
||||
const monacoChunk = resolveMonacoChunk(moduleId, 'vendor-monaco')
|
||||
if (monacoChunk) {
|
||||
return monacoChunk
|
||||
}
|
||||
if (moduleId.includes('/node_modules/@monaco-editor/react/')) return 'vendor-monaco-react'
|
||||
|
||||
if (moduleId.includes('/node_modules/antd/es/')) {
|
||||
return `vendor-antd-${firstSegmentAfter(moduleId, '/node_modules/antd/es/')}`
|
||||
}
|
||||
if (moduleId.includes('/node_modules/antd/')) return 'vendor-antd'
|
||||
if (moduleId.includes('/node_modules/@ant-design/icons/')) return 'vendor-antd-icons'
|
||||
if (moduleId.includes('/node_modules/@ant-design/cssinjs/')) return 'vendor-antd-css'
|
||||
if (moduleId.includes('/node_modules/rc-')) return 'vendor-antd-rc'
|
||||
|
||||
if (moduleId.includes('/node_modules/@dnd-kit/')) return 'vendor-dnd-kit'
|
||||
if (moduleId.includes('/node_modules/sql-formatter/')) return 'vendor-sql-formatter'
|
||||
|
||||
if (
|
||||
moduleId.includes('/node_modules/react/')
|
||||
|| moduleId.includes('/node_modules/react-dom/')
|
||||
|| moduleId.includes('/node_modules/scheduler/')
|
||||
) {
|
||||
return 'vendor-react'
|
||||
}
|
||||
|
||||
if (
|
||||
moduleId.includes('/node_modules/zustand/')
|
||||
|| moduleId.includes('/node_modules/uuid/')
|
||||
|| moduleId.includes('/node_modules/clsx/')
|
||||
|| moduleId.includes('/node_modules/react-resizable/')
|
||||
) {
|
||||
return 'vendor-utils'
|
||||
}
|
||||
|
||||
return 'vendor-misc'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const moduleId = normalizeModuleId(id)
|
||||
if (!moduleId.includes('node_modules')) return undefined
|
||||
return resolveMonacoChunk(moduleId, 'worker-monaco')
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
8
frontend/wailsjs/go/app/App.d.ts
vendored
8
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -6,6 +6,8 @@ import {redis} from '../models';
|
||||
|
||||
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckForUpdates():Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
@@ -38,7 +40,7 @@ export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise<connection.QueryRe
|
||||
|
||||
export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DownloadUpdate():Promise<connection.QueryResult>;
|
||||
|
||||
@@ -66,6 +68,10 @@ export function GetAppInfo():Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverStatusList(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverVersionList(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverVersionPackageSize(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportConfigFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -6,6 +6,10 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function CheckDriverNetworkStatus() {
|
||||
return window['go']['app']['App']['CheckDriverNetworkStatus']();
|
||||
}
|
||||
|
||||
export function CheckForUpdates() {
|
||||
return window['go']['app']['App']['CheckForUpdates']();
|
||||
}
|
||||
@@ -70,8 +74,8 @@ export function DataSyncPreview(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DownloadDriverPackage(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3);
|
||||
export function DownloadDriverPackage(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function DownloadUpdate() {
|
||||
@@ -126,6 +130,14 @@ export function GetDriverStatusList(arg1, arg2) {
|
||||
return window['go']['app']['App']['GetDriverStatusList'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetDriverVersionList(arg1, arg2) {
|
||||
return window['go']['app']['App']['GetDriverVersionList'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetDriverVersionPackageSize(arg1, arg2) {
|
||||
return window['go']['app']['App']['GetDriverVersionPackageSize'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ImportConfigFile() {
|
||||
return window['go']['app']['App']['ImportConfigFile']();
|
||||
}
|
||||
|
||||
@@ -48,6 +48,26 @@ export namespace connection {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class ProxyConfig {
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProxyConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.type = source["type"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.user = source["user"];
|
||||
this.password = source["password"];
|
||||
}
|
||||
}
|
||||
export class SSHConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -78,6 +98,8 @@ export namespace connection {
|
||||
database: string;
|
||||
useSSH: boolean;
|
||||
ssh: SSHConfig;
|
||||
useProxy?: boolean;
|
||||
proxy?: ProxyConfig;
|
||||
driver?: string;
|
||||
dsn?: string;
|
||||
timeout?: number;
|
||||
@@ -110,6 +132,8 @@ export namespace connection {
|
||||
this.database = source["database"];
|
||||
this.useSSH = source["useSSH"];
|
||||
this.ssh = this.convertValues(source["ssh"], SSHConfig);
|
||||
this.useProxy = source["useProxy"];
|
||||
this.proxy = this.convertValues(source["proxy"], ProxyConfig);
|
||||
this.driver = source["driver"];
|
||||
this.dsn = source["dsn"];
|
||||
this.timeout = source["timeout"];
|
||||
@@ -146,6 +170,7 @@ export namespace connection {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
|
||||
4
go.mod
4
go.mod
@@ -17,6 +17,8 @@ require (
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/mod v0.32.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/text v0.33.0
|
||||
modernc.org/sqlite v1.44.3
|
||||
)
|
||||
@@ -83,8 +85,6 @@ require (
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 // indirect
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
proxytunnel "GoNavi-Wails/internal/proxy"
|
||||
)
|
||||
|
||||
const dbCachePingInterval = 30 * time.Second
|
||||
@@ -66,6 +67,7 @@ func (a *App) Shutdown(ctx context.Context) {
|
||||
logger.Error(err, "关闭数据库连接失败")
|
||||
}
|
||||
}
|
||||
proxytunnel.CloseAllForwarders()
|
||||
// Close all Redis connections
|
||||
CloseAllRedisClients()
|
||||
logger.Infof("资源释放完成,应用已关闭")
|
||||
@@ -77,9 +79,8 @@ func getCacheKey(config connection.ConnectionConfig) string {
|
||||
if !config.UseSSH {
|
||||
config.SSH = connection.SSHConfig{}
|
||||
}
|
||||
// 保持与驱动默认一致,避免同一连接被重复缓存
|
||||
if config.Type == "postgres" && config.Database == "" {
|
||||
config.Database = "postgres"
|
||||
if !config.UseProxy {
|
||||
config.Proxy = connection.ProxyConfig{}
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(config)
|
||||
@@ -175,6 +176,12 @@ func formatConnSummary(config connection.ConnectionConfig) string {
|
||||
if config.UseSSH {
|
||||
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))
|
||||
}
|
||||
if config.UseProxy {
|
||||
b.WriteString(fmt.Sprintf(" 代理=%s://%s:%d", strings.ToLower(strings.TrimSpace(config.Proxy.Type)), config.Proxy.Host, config.Proxy.Port))
|
||||
if strings.TrimSpace(config.Proxy.User) != "" {
|
||||
b.WriteString(" 代理认证=已配置")
|
||||
}
|
||||
}
|
||||
|
||||
if config.Type == "custom" {
|
||||
driver := strings.TrimSpace(config.Driver)
|
||||
@@ -269,7 +276,14 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := dbInst.Connect(config); err != nil {
|
||||
connectConfig, proxyErr := resolveDialConfigWithProxy(config)
|
||||
if proxyErr != nil {
|
||||
wrapped := wrapConnectError(config, proxyErr)
|
||||
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||
return nil, wrapped
|
||||
}
|
||||
|
||||
if err := dbInst.Connect(connectConfig); err != nil {
|
||||
wrapped := wrapConnectError(config, err)
|
||||
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||
return nil, wrapped
|
||||
|
||||
202
internal/app/db_proxy.go
Normal file
202
internal/app/db_proxy.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
proxytunnel "GoNavi-Wails/internal/proxy"
|
||||
)
|
||||
|
||||
func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
config := raw
|
||||
if !config.UseProxy {
|
||||
config.Proxy = connection.ProxyConfig{}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
normalizedProxy, err := proxytunnel.NormalizeConfig(config.Proxy)
|
||||
if err != nil {
|
||||
return connection.ConnectionConfig{}, err
|
||||
}
|
||||
config.Proxy = normalizedProxy
|
||||
|
||||
if config.UseSSH {
|
||||
sshPort := config.SSH.Port
|
||||
if sshPort <= 0 {
|
||||
sshPort = 22
|
||||
}
|
||||
forwardedSSH, err := buildProxyForwardAddress(normalizedProxy, strings.TrimSpace(config.SSH.Host), sshPort)
|
||||
if err != nil {
|
||||
return connection.ConnectionConfig{}, fmt.Errorf("代理连接 SSH 网关失败:%w", err)
|
||||
}
|
||||
config.SSH.Host = forwardedSSH.host
|
||||
config.SSH.Port = forwardedSSH.port
|
||||
config.UseProxy = false
|
||||
config.Proxy = connection.ProxyConfig{}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
normalizedType := strings.ToLower(strings.TrimSpace(config.Type))
|
||||
if normalizedType == "sqlite" || normalizedType == "duckdb" || normalizedType == "custom" {
|
||||
// 文件型/自定义 DSN 类型不走标准 host:port,不在此层改写。
|
||||
return config, nil
|
||||
}
|
||||
if normalizedType == "mongodb" && config.MongoSRV {
|
||||
// Mongo SRV 由驱动侧 Dialer 处理代理,避免破坏 DNS SRV 拓扑发现。
|
||||
return config, nil
|
||||
}
|
||||
|
||||
targetPort := config.Port
|
||||
if targetPort <= 0 {
|
||||
targetPort = defaultPortByType(normalizedType)
|
||||
}
|
||||
forwardedPrimary, err := buildProxyForwardAddress(normalizedProxy, strings.TrimSpace(config.Host), targetPort)
|
||||
if err != nil {
|
||||
return connection.ConnectionConfig{}, err
|
||||
}
|
||||
config.Host = forwardedPrimary.host
|
||||
config.Port = forwardedPrimary.port
|
||||
|
||||
if len(config.Hosts) > 0 {
|
||||
rewritten := make([]string, 0, len(config.Hosts))
|
||||
seen := make(map[string]struct{}, len(config.Hosts))
|
||||
for _, rawEntry := range config.Hosts {
|
||||
targetHost, targetPort, ok := parseAddressWithDefaultPort(rawEntry, defaultPortByType(normalizedType))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
forwarded, forwardErr := buildProxyForwardAddress(normalizedProxy, targetHost, targetPort)
|
||||
if forwardErr != nil {
|
||||
return connection.ConnectionConfig{}, forwardErr
|
||||
}
|
||||
rewrittenAddress := formatHostPort(forwarded.host, forwarded.port)
|
||||
if _, exists := seen[rewrittenAddress]; exists {
|
||||
continue
|
||||
}
|
||||
seen[rewrittenAddress] = struct{}{}
|
||||
rewritten = append(rewritten, rewrittenAddress)
|
||||
}
|
||||
config.Hosts = rewritten
|
||||
}
|
||||
|
||||
config.UseProxy = false
|
||||
config.Proxy = connection.ProxyConfig{}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
type hostPort struct {
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func buildProxyForwardAddress(proxyConfig connection.ProxyConfig, targetHost string, targetPort int) (hostPort, error) {
|
||||
host := strings.TrimSpace(targetHost)
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
port := targetPort
|
||||
if port <= 0 {
|
||||
return hostPort{}, fmt.Errorf("目标端口无效:%d", targetPort)
|
||||
}
|
||||
|
||||
forwarder, err := proxytunnel.GetOrCreateLocalForwarder(proxyConfig, host, port)
|
||||
if err != nil {
|
||||
return hostPort{}, err
|
||||
}
|
||||
localHost, localPort, splitOK := parseAddressWithDefaultPort(forwarder.LocalAddr, 0)
|
||||
if !splitOK || localPort <= 0 {
|
||||
return hostPort{}, fmt.Errorf("解析代理本地转发地址失败:%s", forwarder.LocalAddr)
|
||||
}
|
||||
return hostPort{host: localHost, port: localPort}, nil
|
||||
}
|
||||
|
||||
func parseAddressWithDefaultPort(raw string, defaultPort int) (string, int, bool) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(text, "[") {
|
||||
if host, portText, err := net.SplitHostPort(text); err == nil {
|
||||
if port, convErr := strconv.Atoi(portText); convErr == nil && port > 0 && port <= 65535 {
|
||||
return strings.TrimSpace(host), port, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
trimmed := strings.Trim(strings.TrimPrefix(text, "["), "]")
|
||||
if trimmed != "" && defaultPort > 0 {
|
||||
return trimmed, defaultPort, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
if strings.Count(text, ":") == 0 {
|
||||
if defaultPort <= 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
return text, defaultPort, true
|
||||
}
|
||||
|
||||
if strings.Count(text, ":") == 1 {
|
||||
host, portText, err := net.SplitHostPort(text)
|
||||
if err == nil {
|
||||
port, convErr := strconv.Atoi(portText)
|
||||
if convErr == nil && port > 0 && port <= 65535 {
|
||||
return strings.TrimSpace(host), port, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
if defaultPort > 0 {
|
||||
return strings.TrimSpace(text), defaultPort, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// IPv6 地址未带端口,使用默认端口。
|
||||
if defaultPort > 0 {
|
||||
return text, defaultPort, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
func formatHostPort(host string, port int) string {
|
||||
h := strings.TrimSpace(host)
|
||||
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
|
||||
return fmt.Sprintf("[%s]:%d", h, port)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", h, port)
|
||||
}
|
||||
|
||||
func defaultPortByType(driverType string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(driverType)) {
|
||||
case "mysql", "mariadb":
|
||||
return 3306
|
||||
case "diros":
|
||||
return 9030
|
||||
case "sphinx":
|
||||
return 9306
|
||||
case "postgres", "vastbase":
|
||||
return 5432
|
||||
case "redis":
|
||||
return 6379
|
||||
case "tdengine":
|
||||
return 6041
|
||||
case "oracle":
|
||||
return 1521
|
||||
case "dameng":
|
||||
return 5236
|
||||
case "kingbase":
|
||||
return 54321
|
||||
case "sqlserver":
|
||||
return 1433
|
||||
case "mongodb":
|
||||
return 27017
|
||||
case "highgo":
|
||||
return 5866
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -190,9 +190,6 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string,
|
||||
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"}
|
||||
}
|
||||
runConfig := config
|
||||
if strings.TrimSpace(runConfig.Database) == "" {
|
||||
runConfig.Database = "postgres"
|
||||
}
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -228,9 +225,6 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co
|
||||
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再删除"}
|
||||
}
|
||||
runConfig = config
|
||||
if strings.TrimSpace(runConfig.Database) == "" {
|
||||
runConfig.Database = "postgres"
|
||||
}
|
||||
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除数据库", dbType)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,33 +9,44 @@ type SSHConfig struct {
|
||||
KeyPath string `json:"keyPath"`
|
||||
}
|
||||
|
||||
// ProxyConfig holds proxy connection details
|
||||
type ProxyConfig struct {
|
||||
Type string `json:"type"` // socks5 | http
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// ConnectionConfig holds database connection details including SSH
|
||||
type ConnectionConfig struct {
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
|
||||
Database string `json:"database"`
|
||||
UseSSH bool `json:"useSSH"`
|
||||
SSH SSHConfig `json:"ssh"`
|
||||
Driver string `json:"driver,omitempty"` // For custom connection
|
||||
DSN string `json:"dsn,omitempty"` // For custom connection
|
||||
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
|
||||
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
|
||||
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
|
||||
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
|
||||
Topology string `json:"topology,omitempty"` // single | replica
|
||||
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
|
||||
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
|
||||
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
|
||||
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
|
||||
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
|
||||
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
|
||||
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
|
||||
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
|
||||
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
|
||||
Database string `json:"database"`
|
||||
UseSSH bool `json:"useSSH"`
|
||||
SSH SSHConfig `json:"ssh"`
|
||||
UseProxy bool `json:"useProxy,omitempty"`
|
||||
Proxy ProxyConfig `json:"proxy,omitempty"`
|
||||
Driver string `json:"driver,omitempty"` // For custom connection
|
||||
DSN string `json:"dsn,omitempty"` // For custom connection
|
||||
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
|
||||
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
|
||||
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
|
||||
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
|
||||
Topology string `json:"topology,omitempty"` // single | replica
|
||||
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
|
||||
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
|
||||
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
|
||||
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
|
||||
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
|
||||
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
|
||||
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
|
||||
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
|
||||
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
|
||||
}
|
||||
|
||||
// QueryResult is the standard response format for Wails methods
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
proxytunnel "GoNavi-Wails/internal/proxy"
|
||||
"GoNavi-Wails/internal/ssh"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -29,6 +30,14 @@ type MongoDB struct {
|
||||
forwarder *ssh.LocalForwarder
|
||||
}
|
||||
|
||||
type mongoProxyDialer struct {
|
||||
proxyConfig connection.ProxyConfig
|
||||
}
|
||||
|
||||
func (d *mongoProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return proxytunnel.DialContext(ctx, d.proxyConfig, network, address)
|
||||
}
|
||||
|
||||
const defaultMongoPort = 27017
|
||||
|
||||
func normalizeMongoAddress(host string, port int) string {
|
||||
@@ -328,6 +337,9 @@ func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
|
||||
|
||||
uri := m.getURI(attemptConfig)
|
||||
clientOpts := options.Client().ApplyURI(uri)
|
||||
if attemptConfig.UseProxy {
|
||||
clientOpts.SetDialer(&mongoProxyDialer{proxyConfig: attemptConfig.Proxy})
|
||||
}
|
||||
client, err := mongo.Connect(clientOpts)
|
||||
if err != nil {
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s连接失败: %v", authLabel, err))
|
||||
|
||||
48
internal/db/postgres_connect_test.go
Normal file
48
internal/db/postgres_connect_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestResolvePostgresConnectDatabases_ExplicitDatabase(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "postgres",
|
||||
Database: "analytics",
|
||||
User: "app_user",
|
||||
}
|
||||
|
||||
got := resolvePostgresConnectDatabases(cfg)
|
||||
want := []string{"analytics"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePostgresConnectDatabases_FallbackOrder(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "postgres",
|
||||
User: "app_user",
|
||||
}
|
||||
|
||||
got := resolvePostgresConnectDatabases(cfg)
|
||||
want := []string{"postgres", "template1", "app_user"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePostgresConnectDatabases_DeduplicateUserDefault(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "postgres",
|
||||
User: "postgres",
|
||||
}
|
||||
|
||||
got := resolvePostgresConnectDatabases(cfg)
|
||||
want := []string{"postgres", "template1"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,30 @@ type PostgresDB struct {
|
||||
forwarder *ssh.LocalForwarder // Store SSH tunnel forwarder
|
||||
}
|
||||
|
||||
func resolvePostgresConnectDatabases(config connection.ConnectionConfig) []string {
|
||||
explicit := strings.TrimSpace(config.Database)
|
||||
if explicit != "" {
|
||||
return []string{explicit}
|
||||
}
|
||||
|
||||
candidates := []string{"postgres", "template1", strings.TrimSpace(config.User)}
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
result := make([]string, 0, len(candidates))
|
||||
for _, name := range candidates {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
normalized := strings.ToLower(trimmed)
|
||||
if _, exists := seen[normalized]; exists {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
|
||||
// postgres://user:password@host:port/dbname?sslmode=disable
|
||||
dbname := config.Database
|
||||
@@ -53,8 +77,23 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
|
||||
return fmt.Errorf("%s", reason)
|
||||
}
|
||||
|
||||
var dsn string
|
||||
var err error
|
||||
runConfig := config
|
||||
p.pingTimeout = getConnectTimeout(config)
|
||||
|
||||
cleanupOnFailure := true
|
||||
defer func() {
|
||||
if !cleanupOnFailure {
|
||||
return
|
||||
}
|
||||
if p.conn != nil {
|
||||
_ = p.conn.Close()
|
||||
p.conn = nil
|
||||
}
|
||||
if p.forwarder != nil {
|
||||
_ = p.forwarder.Close()
|
||||
p.forwarder = nil
|
||||
}
|
||||
}()
|
||||
|
||||
if config.UseSSH {
|
||||
// Create SSH tunnel with local port forwarding
|
||||
@@ -83,24 +122,44 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
|
||||
localConfig.Port = port
|
||||
localConfig.UseSSH = false // Disable SSH flag for DSN generation
|
||||
|
||||
dsn = p.getDSN(localConfig)
|
||||
runConfig = localConfig
|
||||
logger.Infof("PostgreSQL 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
|
||||
} else {
|
||||
dsn = p.getDSN(config)
|
||||
}
|
||||
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||
}
|
||||
p.conn = db
|
||||
p.pingTimeout = getConnectTimeout(config)
|
||||
attemptDBs := resolvePostgresConnectDatabases(runConfig)
|
||||
var failures []string
|
||||
for _, dbName := range attemptDBs {
|
||||
attemptConfig := runConfig
|
||||
attemptConfig.Database = dbName
|
||||
dsn := p.getDSN(attemptConfig)
|
||||
|
||||
// Force verification
|
||||
if err := p.Ping(); err != nil {
|
||||
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||
dbConn, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
failures = append(failures, fmt.Sprintf("数据库=%s 打开连接失败: %v", dbName, err))
|
||||
continue
|
||||
}
|
||||
p.conn = dbConn
|
||||
|
||||
// Force verification
|
||||
if err := p.Ping(); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("数据库=%s 验证失败: %v", dbName, err))
|
||||
_ = dbConn.Close()
|
||||
p.conn = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.Database) == "" && !strings.EqualFold(dbName, "postgres") {
|
||||
logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName)
|
||||
}
|
||||
|
||||
cleanupOnFailure = false
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
|
||||
if len(failures) == 0 {
|
||||
return fmt.Errorf("连接建立后验证失败:未找到可用的连接数据库")
|
||||
}
|
||||
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(failures, ";"))
|
||||
}
|
||||
|
||||
func (p *PostgresDB) Close() error {
|
||||
|
||||
344
internal/proxy/proxy.go
Normal file
344
internal/proxy/proxy.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
|
||||
xproxy "golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDialTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
type LocalForwarder struct {
|
||||
LocalAddr string
|
||||
RemoteAddr string
|
||||
ProxyAddr string
|
||||
ProxyType string
|
||||
|
||||
cfg connection.ProxyConfig
|
||||
listener net.Listener
|
||||
closeChan chan struct{}
|
||||
closeOnce sync.Once
|
||||
|
||||
closed bool
|
||||
closedMu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
forwarderMu sync.RWMutex
|
||||
localForwarders = make(map[string]*LocalForwarder)
|
||||
)
|
||||
|
||||
func NormalizeConfig(config connection.ProxyConfig) (connection.ProxyConfig, error) {
|
||||
result := connection.ProxyConfig{
|
||||
Type: strings.ToLower(strings.TrimSpace(config.Type)),
|
||||
Host: strings.TrimSpace(config.Host),
|
||||
Port: config.Port,
|
||||
User: strings.TrimSpace(config.User),
|
||||
Password: config.Password,
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case "socks5", "socks5h", "http":
|
||||
default:
|
||||
return result, fmt.Errorf("不支持的代理类型:%s", config.Type)
|
||||
}
|
||||
if result.Type == "socks5h" {
|
||||
result.Type = "socks5"
|
||||
}
|
||||
if result.Host == "" {
|
||||
return result, fmt.Errorf("代理主机为空")
|
||||
}
|
||||
if result.Port <= 0 || result.Port > 65535 {
|
||||
return result, fmt.Errorf("代理端口无效:%d", result.Port)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func GetOrCreateLocalForwarder(proxyConfig connection.ProxyConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
|
||||
cfg, err := NormalizeConfig(proxyConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(remoteHost) == "" || remotePort <= 0 {
|
||||
return nil, fmt.Errorf("无效的远端地址:%s:%d", remoteHost, remotePort)
|
||||
}
|
||||
|
||||
key := forwarderCacheKey(cfg, remoteHost, remotePort)
|
||||
forwarderMu.RLock()
|
||||
forwarder, exists := localForwarders[key]
|
||||
forwarderMu.RUnlock()
|
||||
if exists && forwarder != nil && !forwarder.IsClosed() {
|
||||
return forwarder, nil
|
||||
}
|
||||
|
||||
if exists {
|
||||
forwarderMu.Lock()
|
||||
delete(localForwarders, key)
|
||||
forwarderMu.Unlock()
|
||||
}
|
||||
|
||||
next, err := NewLocalForwarder(cfg, remoteHost, remotePort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
forwarderMu.Lock()
|
||||
localForwarders[key] = next
|
||||
forwarderMu.Unlock()
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func forwarderCacheKey(cfg connection.ProxyConfig, remoteHost string, remotePort int) string {
|
||||
trimmedHost := strings.TrimSpace(remoteHost)
|
||||
credential := cfg.User + "\x00" + cfg.Password
|
||||
credentialHash := sha256.Sum256([]byte(credential))
|
||||
// 仅保留短指纹用于区分不同认证信息,避免在 key 日志中泄露明文口令。
|
||||
fingerprint := hex.EncodeToString(credentialHash[:8])
|
||||
return fmt.Sprintf("%s://%s:%d@%s:%d#%s", cfg.Type, cfg.Host, cfg.Port, trimmedHost, remotePort, fingerprint)
|
||||
}
|
||||
|
||||
func NewLocalForwarder(proxyConfig connection.ProxyConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
|
||||
cfg, err := NormalizeConfig(proxyConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建本地代理监听失败:%w", err)
|
||||
}
|
||||
|
||||
localAddr := listener.Addr().String()
|
||||
remoteAddr := net.JoinHostPort(strings.TrimSpace(remoteHost), fmt.Sprintf("%d", remotePort))
|
||||
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
||||
forwarder := &LocalForwarder{
|
||||
LocalAddr: localAddr,
|
||||
RemoteAddr: remoteAddr,
|
||||
ProxyAddr: proxyAddr,
|
||||
ProxyType: cfg.Type,
|
||||
cfg: cfg,
|
||||
listener: listener,
|
||||
closeChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
go forwarder.forward()
|
||||
logger.Infof("已创建代理端口转发:本地 %s -> 远端 %s(代理 %s://%s)", localAddr, remoteAddr, cfg.Type, proxyAddr)
|
||||
return forwarder, nil
|
||||
}
|
||||
|
||||
func (f *LocalForwarder) forward() {
|
||||
for {
|
||||
localConn, err := f.listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-f.closeChan:
|
||||
return
|
||||
default:
|
||||
logger.Warnf("接受本地代理连接失败:%v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
go f.handleConnection(localConn)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *LocalForwarder) handleConnection(localConn net.Conn) {
|
||||
defer localConn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultDialTimeout)
|
||||
remoteConn, err := dialThroughProxy(ctx, f.cfg, "tcp", f.RemoteAddr)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Warnf("通过代理连接远端失败:远端=%s 代理=%s://%s 错误=%v", f.RemoteAddr, f.ProxyType, f.ProxyAddr, err)
|
||||
return
|
||||
}
|
||||
defer remoteConn.Close()
|
||||
|
||||
errc := make(chan error, 2)
|
||||
var closeOnce sync.Once
|
||||
closeBoth := func() {
|
||||
_ = localConn.Close()
|
||||
_ = remoteConn.Close()
|
||||
}
|
||||
go func() {
|
||||
_, copyErr := io.Copy(remoteConn, localConn)
|
||||
closeOnce.Do(closeBoth)
|
||||
errc <- copyErr
|
||||
}()
|
||||
go func() {
|
||||
_, copyErr := io.Copy(localConn, remoteConn)
|
||||
closeOnce.Do(closeBoth)
|
||||
errc <- copyErr
|
||||
}()
|
||||
<-errc
|
||||
<-errc
|
||||
}
|
||||
|
||||
func (f *LocalForwarder) Close() error {
|
||||
var err error
|
||||
f.closeOnce.Do(func() {
|
||||
f.closedMu.Lock()
|
||||
f.closed = true
|
||||
f.closedMu.Unlock()
|
||||
close(f.closeChan)
|
||||
err = f.listener.Close()
|
||||
if err != nil {
|
||||
logger.Warnf("关闭代理端口转发失败:%v", err)
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *LocalForwarder) IsClosed() bool {
|
||||
f.closedMu.RLock()
|
||||
defer f.closedMu.RUnlock()
|
||||
return f.closed
|
||||
}
|
||||
|
||||
func CloseAllForwarders() {
|
||||
forwarderMu.Lock()
|
||||
defer forwarderMu.Unlock()
|
||||
|
||||
for key, forwarder := range localForwarders {
|
||||
if forwarder == nil {
|
||||
continue
|
||||
}
|
||||
_ = forwarder.Close()
|
||||
logger.Infof("已关闭代理端口转发:%s", key)
|
||||
}
|
||||
localForwarders = make(map[string]*LocalForwarder)
|
||||
}
|
||||
|
||||
func DialContext(ctx context.Context, proxyConfig connection.ProxyConfig, network, address string) (net.Conn, error) {
|
||||
cfg, err := NormalizeConfig(proxyConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dialThroughProxy(ctx, cfg, network, address)
|
||||
}
|
||||
|
||||
func dialThroughProxy(ctx context.Context, cfg connection.ProxyConfig, network, address string) (net.Conn, error) {
|
||||
switch cfg.Type {
|
||||
case "socks5":
|
||||
return dialSOCKS5(ctx, cfg, network, address)
|
||||
case "http":
|
||||
return dialHTTPConnect(ctx, cfg, address)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的代理类型:%s", cfg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func dialSOCKS5(ctx context.Context, cfg connection.ProxyConfig, network, address string) (net.Conn, error) {
|
||||
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
||||
var auth *xproxy.Auth
|
||||
if cfg.User != "" || cfg.Password != "" {
|
||||
auth = &xproxy.Auth{
|
||||
User: cfg.User,
|
||||
Password: cfg.Password,
|
||||
}
|
||||
}
|
||||
dialer, err := xproxy.SOCKS5("tcp", proxyAddr, auth, &net.Dialer{Timeout: defaultDialTimeout})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 SOCKS5 代理拨号器失败:%w", err)
|
||||
}
|
||||
|
||||
type result struct {
|
||||
conn net.Conn
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
conn, dialErr := dialer.Dial(network, address)
|
||||
ch <- result{conn: conn, err: dialErr}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
go func() {
|
||||
r := <-ch
|
||||
if r.conn != nil {
|
||||
_ = r.conn.Close()
|
||||
}
|
||||
}()
|
||||
return nil, ctx.Err()
|
||||
case r := <-ch:
|
||||
if r.err != nil {
|
||||
return nil, fmt.Errorf("SOCKS5 代理连接失败:%w", r.err)
|
||||
}
|
||||
return r.conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func dialHTTPConnect(ctx context.Context, cfg connection.ProxyConfig, address string) (net.Conn, error) {
|
||||
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
||||
dialer := &net.Dialer{Timeout: defaultDialTimeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", proxyAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接 HTTP 代理失败:%w", err)
|
||||
}
|
||||
|
||||
connectReq := &http.Request{
|
||||
Method: http.MethodConnect,
|
||||
URL: &url.URL{Opaque: address},
|
||||
Host: address,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
if cfg.User != "" || cfg.Password != "" {
|
||||
raw := cfg.User + ":" + cfg.Password
|
||||
connectReq.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(raw)))
|
||||
}
|
||||
if err := connectReq.Write(conn); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("发送 HTTP CONNECT 请求失败:%w", err)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(reader, connectReq)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("读取 HTTP CONNECT 响应失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("HTTP 代理 CONNECT 失败:%s", strings.TrimSpace(resp.Status))
|
||||
}
|
||||
|
||||
if reader.Buffered() == 0 {
|
||||
return conn, nil
|
||||
}
|
||||
return &bufferedConn{Conn: conn, reader: reader}, nil
|
||||
}
|
||||
|
||||
type bufferedConn struct {
|
||||
net.Conn
|
||||
reader *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *bufferedConn) Read(p []byte) (int, error) {
|
||||
if c.reader == nil {
|
||||
return c.Conn.Read(p)
|
||||
}
|
||||
if c.reader.Buffered() == 0 {
|
||||
c.reader = nil
|
||||
return c.Conn.Read(p)
|
||||
}
|
||||
return c.reader.Read(p)
|
||||
}
|
||||
44
internal/proxy/proxy_test.go
Normal file
44
internal/proxy/proxy_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestNormalizeConfigSupportsSocks5hAlias(t *testing.T) {
|
||||
cfg, err := NormalizeConfig(connection.ProxyConfig{
|
||||
Type: "SOCKS5H",
|
||||
Host: "127.0.0.1",
|
||||
Port: 1080,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeConfig returned error: %v", err)
|
||||
}
|
||||
if cfg.Type != "socks5" {
|
||||
t.Fatalf("expected normalized proxy type socks5, got %s", cfg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwarderCacheKeyIncludesCredentialFingerprint(t *testing.T) {
|
||||
base := connection.ProxyConfig{
|
||||
Type: "socks5",
|
||||
Host: "127.0.0.1",
|
||||
Port: 1080,
|
||||
User: "tester",
|
||||
Password: "first-password",
|
||||
}
|
||||
other := base
|
||||
other.Password = "second-password"
|
||||
|
||||
keyA := forwarderCacheKey(base, "db.internal", 3306)
|
||||
keyB := forwarderCacheKey(other, "db.internal", 3306)
|
||||
|
||||
if keyA == keyB {
|
||||
t.Fatalf("expected different cache key for different credentials")
|
||||
}
|
||||
if strings.Contains(keyA, base.Password) || strings.Contains(keyB, other.Password) {
|
||||
t.Fatalf("cache key should not contain raw password")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,14 @@ type RedisClientImpl struct {
|
||||
forwarder *ssh.LocalForwarder
|
||||
}
|
||||
|
||||
const (
|
||||
redisScanDefaultTargetCount int64 = 2000
|
||||
redisScanMaxTargetCount int64 = 10000
|
||||
redisScanMinStepCount int64 = 200
|
||||
redisScanMaxStepCount int64 = 2000
|
||||
redisScanMaxRounds = 64
|
||||
)
|
||||
|
||||
// NewRedisClient creates a new Redis client instance
|
||||
func NewRedisClient() RedisClient {
|
||||
return &RedisClientImpl{}
|
||||
@@ -108,21 +116,70 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
targetCount := normalizeRedisScanTargetCount(count)
|
||||
scanStepCount := normalizeRedisScanStepCount(targetCount)
|
||||
currentCursor := cursor
|
||||
round := 0
|
||||
|
||||
keys := make([]string, 0, int(targetCount))
|
||||
seen := make(map[string]struct{}, int(targetCount))
|
||||
|
||||
for len(keys) < int(targetCount) {
|
||||
batch, nextCursor, err := r.client.Scan(ctx, currentCursor, pattern, scanStepCount).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, key := range batch {
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
keys = append(keys, key)
|
||||
if len(keys) >= int(targetCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
currentCursor = nextCursor
|
||||
round++
|
||||
if currentCursor == 0 || round >= redisScanMaxRounds {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &RedisScanResult{
|
||||
Keys: r.loadRedisKeyInfos(ctx, keys),
|
||||
Cursor: currentCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeRedisScanTargetCount(count int64) int64 {
|
||||
if count <= 0 {
|
||||
count = 100
|
||||
return redisScanDefaultTargetCount
|
||||
}
|
||||
if count > redisScanMaxTargetCount {
|
||||
return redisScanMaxTargetCount
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func normalizeRedisScanStepCount(targetCount int64) int64 {
|
||||
if targetCount < redisScanMinStepCount {
|
||||
return redisScanMinStepCount
|
||||
}
|
||||
if targetCount > redisScanMaxStepCount {
|
||||
return redisScanMaxStepCount
|
||||
}
|
||||
return targetCount
|
||||
}
|
||||
|
||||
func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string) []RedisKeyInfo {
|
||||
result := make([]RedisKeyInfo, 0, len(keys))
|
||||
if len(keys) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, count).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &RedisScanResult{
|
||||
Keys: make([]RedisKeyInfo, 0, len(keys)),
|
||||
Cursor: nextCursor,
|
||||
}
|
||||
|
||||
// Get type and TTL for each key
|
||||
pipe := r.client.Pipeline()
|
||||
typeResults := make([]*redis.StatusCmd, len(keys))
|
||||
ttlResults := make([]*redis.DurationCmd, len(keys))
|
||||
@@ -132,37 +189,44 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
|
||||
ttlResults[i] = pipe.TTL(ctx, key)
|
||||
}
|
||||
|
||||
_, err = pipe.Exec(ctx)
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil && err != redis.Nil {
|
||||
// Fallback: get info one by one
|
||||
for _, key := range keys {
|
||||
keyType, _ := r.GetKeyType(key)
|
||||
ttl, _ := r.GetTTL(key)
|
||||
result.Keys = append(result.Keys, RedisKeyInfo{
|
||||
keyType, typeErr := r.client.Type(ctx, key).Result()
|
||||
if typeErr != nil && typeErr != redis.Nil {
|
||||
keyType = ""
|
||||
}
|
||||
ttlValue, ttlErr := r.client.TTL(ctx, key).Result()
|
||||
if ttlErr != nil && ttlErr != redis.Nil {
|
||||
ttlValue = -2
|
||||
}
|
||||
result = append(result, RedisKeyInfo{
|
||||
Key: key,
|
||||
Type: keyType,
|
||||
TTL: ttl,
|
||||
TTL: toRedisTTLSeconds(ttlValue),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
return result
|
||||
}
|
||||
|
||||
for i, key := range keys {
|
||||
keyType := typeResults[i].Val()
|
||||
ttl := int64(ttlResults[i].Val().Seconds())
|
||||
if ttlResults[i].Val() == -1 {
|
||||
ttl = -1
|
||||
} else if ttlResults[i].Val() == -2 {
|
||||
ttl = -2
|
||||
}
|
||||
result.Keys = append(result.Keys, RedisKeyInfo{
|
||||
result = append(result, RedisKeyInfo{
|
||||
Key: key,
|
||||
Type: keyType,
|
||||
TTL: ttl,
|
||||
Type: typeResults[i].Val(),
|
||||
TTL: toRedisTTLSeconds(ttlResults[i].Val()),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return result, nil
|
||||
func toRedisTTLSeconds(ttl time.Duration) int64 {
|
||||
if ttl == -1 {
|
||||
return -1
|
||||
}
|
||||
if ttl == -2 {
|
||||
return -2
|
||||
}
|
||||
return int64(ttl.Seconds())
|
||||
}
|
||||
|
||||
// GetKeyType returns the type of a key
|
||||
|
||||
1
main.go
1
main.go
@@ -41,6 +41,7 @@ func main() {
|
||||
BackdropType: windows.Acrylic,
|
||||
DisableWindowIcon: false,
|
||||
DisableFramelessWindowDecorations: false,
|
||||
WebviewUserDataPath: resolveWindowsWebviewUserDataPath(),
|
||||
},
|
||||
Mac: &mac.Options{
|
||||
WebviewIsTransparent: true,
|
||||
|
||||
123
main_windows_webview_userdata.go
Normal file
123
main_windows_webview_userdata.go
Normal file
@@ -0,0 +1,123 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func resolveWindowsWebviewUserDataPath() string {
|
||||
appDataDir := strings.TrimSpace(os.Getenv("APPDATA"))
|
||||
if appDataDir == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(appDataDir, "GoNavi", "WebView2")
|
||||
_ = migrateLegacyWindowsWebviewUserData(appDataDir, targetDir)
|
||||
return targetDir
|
||||
}
|
||||
|
||||
func migrateLegacyWindowsWebviewUserData(appDataDir, targetDir string) error {
|
||||
if dirHasContent(targetDir) {
|
||||
return nil
|
||||
}
|
||||
|
||||
exeName := "GoNavi.exe"
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
base := strings.TrimSpace(filepath.Base(exePath))
|
||||
if base != "" {
|
||||
exeName = base
|
||||
}
|
||||
}
|
||||
exeBase := strings.TrimSuffix(exeName, filepath.Ext(exeName))
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(appDataDir, exeName),
|
||||
filepath.Join(appDataDir, exeBase),
|
||||
filepath.Join(appDataDir, "GoNavi.exe"),
|
||||
filepath.Join(appDataDir, "GoNavi"),
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
src := filepath.Clean(strings.TrimSpace(candidate))
|
||||
if src == "" || strings.EqualFold(src, filepath.Clean(targetDir)) {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(src)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
|
||||
if !dirHasContent(src) {
|
||||
continue
|
||||
}
|
||||
return copyDirTree(src, targetDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dirHasContent(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
return err == nil && len(entries) > 0
|
||||
}
|
||||
|
||||
func copyDirTree(srcDir, dstDir string) error {
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return filepath.WalkDir(srcDir, func(srcPath string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
relPath, err := filepath.Rel(srcDir, srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
dstPath := filepath.Join(dstDir, relPath)
|
||||
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(dstPath, 0o755)
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return copyFileWithMode(srcPath, dstPath, info.Mode())
|
||||
})
|
||||
}
|
||||
|
||||
func copyFileWithMode(srcPath, dstPath string, mode os.FileMode) error {
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
7
main_windows_webview_userdata_stub.go
Normal file
7
main_windows_webview_userdata_stub.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func resolveWindowsWebviewUserDataPath() string {
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user