From 6a38384449006805cf48516ec0647aee77e05408 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Tue, 26 May 2026 19:21:06 +0800 Subject: [PATCH 01/12] feat: add network proxy settings for HTTP/SOCKS5 proxy support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add proxy configuration in settings UI (Electron client and backend-connected browser modes). Users can enable HTTP or SOCKS5 proxy, configure host/port and optional auth credentials. Proxy applies to all external requests (GitHub API, AI providers, WebDAV) but not to backend communication. - Add ProxyConfig type and Zustand store persistence (migration v5→v6) - Add NetworkPanel UI with toggle, type selection, host/port, auth, test, save - Add Electron IPC bridge (preload.js + session.setProxy with bypass rules) - Replace fetch with axios in backend proxyService for proxy support - Add proxy config API endpoints (GET/PUT/POST test) in backend routes - All proxyRequest calls pass proxyConfig for consistent proxy behavior Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build-desktop.yml | 80 ++++++- server/package-lock.json | 119 +++++++++- server/package.json | 3 +- server/src/routes/proxy.ts | 106 ++++++++- server/src/services/proxyService.ts | 84 +++++-- src/components/settings/GeneralPanel.tsx | 3 + src/components/settings/NetworkPanel.tsx | 289 +++++++++++++++++++++++ src/services/electronProxy.ts | 36 +++ src/store/useAppStore.ts | 44 +++- src/types/index.ts | 14 ++ 10 files changed, 730 insertions(+), 48 deletions(-) create mode 100644 src/components/settings/NetworkPanel.tsx create mode 100644 src/services/electronProxy.ts diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index cb14c897..59eea9d3 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -187,7 +187,7 @@ jobs: fs.mkdirSync('electron', { recursive: true }); } - const mainJsContent = 'const { app, BrowserWindow, Menu, shell, globalShortcut } = require(\\'electron\\');\\n' + + const mainJsContent = 'const { app, BrowserWindow, Menu, shell, globalShortcut, ipcMain } = require(\\'electron\\');\\n' + 'const path = require(\\'path\\');\\n' + 'const fs = require(\\'fs\\');\\n' + 'const isDev = process.env.NODE_ENV === \\'development\\';\\n\\n' + @@ -204,7 +204,8 @@ jobs: ' enableRemoteModule: false,\\n' + ' webSecurity: false,\\n' + ' allowRunningInsecureContent: true,\\n' + - ' devTools: true // 生产环境也允许 DevTools 便于排障\\n' + + ' devTools: true, // 生产环境也允许 DevTools 便于排障\\n' + + ' preload: path.join(__dirname, \\'preload.js\\')\\n' + ' },\\n' + ' icon: path.join(__dirname, \\'../build/icon.png\\'),\\n' + ' titleBarStyle: \\'default\\', // 使用默认标题栏,避免重叠问题\\n' + @@ -375,8 +376,71 @@ jobs: ' mainWindow = null;\\n' + ' });\\n' + '}\\n\\n' + + 'const PROXY_CONFIG_PATH = path.join(app.getPath(\\'userData\\'), \\'proxy-config.json\\');\\n\\n' + + 'function loadProxyConfig() {\\n' + + ' try {\\n' + + ' if (fs.existsSync(PROXY_CONFIG_PATH)) {\\n' + + ' return JSON.parse(fs.readFileSync(PROXY_CONFIG_PATH, \\'utf-8\\'));\\n' + + ' }\\n' + + ' } catch (e) { console.error(\\'Failed to load proxy config:\\', e); }\\n' + + ' return { enabled: false, type: \\'http\\', host: \\'\\', port: 7890 };\\n' + + '}\\n\\n' + + 'function saveProxyConfig(config) {\\n' + + ' fs.writeFileSync(PROXY_CONFIG_PATH, JSON.stringify(config, null, 2));\\n' + + '}\\n\\n' + + 'async function applyProxy(config) {\\n' + + ' if (!mainWindow || mainWindow.isDestroyed()) return;\\n' + + ' if (config.enabled && config.host && config.port) {\\n' + + ' const proxyUrl = config.type === \\'socks5\\'\\n' + + ' ? \\'socks5://\\' + config.host + \\':\\' + config.port\\n' + + ' : \\'http://\\' + config.host + \\':\\' + config.port;\\n' + + ' await mainWindow.webContents.session.setProxy({\\n' + + ' proxyRules: proxyUrl,\\n' + + ' proxyBypassRules: \\';localhost;127.0.0.1\\'\\n' + + ' });\\n' + + ' console.log(\\'[Proxy] Applied:\\', proxyUrl);\\n' + + ' } else {\\n' + + ' await mainWindow.webContents.session.setProxy({ proxyRules: \\'direct:\\\\' });\\n' + + ' console.log(\\'[Proxy] Disabled, using direct connection\\');\\n' + + ' }\\n' + + '}\\n\\n' + + 'ipcMain.handle(\\'set-proxy\\', async (event, config) => {\\n' + + ' saveProxyConfig(config);\\n' + + ' await applyProxy(config);\\n' + + ' return { success: true };\\n' + + '});\\n\\n' + + 'ipcMain.handle(\\'get-proxy\\', () => {\\n' + + ' return loadProxyConfig();\\n' + + '});\\n\\n' + + 'ipcMain.handle(\\'test-proxy\\', async (event, config) => {\\n' + + ' try {\\n' + + ' const net = require(\\'net\\');\\n' + + ' return new Promise((resolve) => {\\n' + + ' const socket = new net.Socket();\\n' + + ' socket.setTimeout(5000);\\n' + + ' socket.on(\\'connect\\', () => {\\n' + + ' socket.destroy();\\n' + + ' resolve({ success: true });\\n' + + ' });\\n' + + ' socket.on(\\'timeout\\', () => {\\n' + + ' socket.destroy();\\n' + + ' resolve({ success: false, error: \\'Connection timeout\\' });\\n' + + ' });\\n' + + ' socket.on(\\'error\\', (err) => {\\n' + + ' resolve({ success: false, error: err.message });\\n' + + ' });\\n' + + ' socket.connect(config.port, config.host);\\n' + + ' });\\n' + + ' } catch (e) {\\n' + + ' return { success: false, error: e.message };\\n' + + ' }\\n' + + '});\\n\\n' + 'app.whenReady().then(() => {\\n' + ' createWindow();\\n' + + ' const savedProxy = loadProxyConfig();\\n' + + ' if (savedProxy.enabled && savedProxy.host && savedProxy.port) {\\n' + + ' applyProxy(savedProxy);\\n' + + ' }\\n' + ' globalShortcut.register(\\'CommandOrControl+Shift+I\\', () => {\\n' + ' const focused = BrowserWindow.getFocusedWindow();\\n' + ' if (focused && !focused.isDestroyed()) {\\n' + @@ -399,7 +463,17 @@ jobs: '});'; fs.writeFileSync('electron/main.js', mainJsContent); - + + const preloadJsContent = `const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('electronAPI', { + setProxy: (config) => ipcRenderer.invoke('set-proxy', config), + getProxy: () => ipcRenderer.invoke('get-proxy'), + testProxy: (config) => ipcRenderer.invoke('test-proxy', config), +}); +`; + fs.writeFileSync('electron/preload.js', preloadJsContent); + const electronPackageJson = { name: 'github-stars-manager-desktop', version: '1.0.0', diff --git a/server/package-lock.json b/server/package-lock.json index 32a2bd50..649ca987 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,6 +8,7 @@ "name": "github-stars-manager-server", "version": "0.1.0", "dependencies": { + "axios": "^1.7.0", "better-sqlite3": "^11.0.0", "cors": "^2.8.5", "express": "^4.21.0", @@ -1174,6 +1175,41 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -1214,9 +1250,20 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1424,7 +1471,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -1575,7 +1621,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -1702,7 +1747,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1891,11 +1935,30 @@ "node": ">= 0.8" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -2079,7 +2142,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -2132,6 +2194,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -2739,6 +2837,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", diff --git a/server/package.json b/server/package.json index eb19c2eb..6f87d6ae 100644 --- a/server/package.json +++ b/server/package.json @@ -15,7 +15,8 @@ "better-sqlite3": "^11.0.0", "cors": "^2.8.5", "helmet": "^7.1.0", - "morgan": "^1.10.0" + "morgan": "^1.10.0", + "axios": "^1.7.0" }, "devDependencies": { "@types/express": "^4.17.21", diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index 2921b089..852a360a 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -2,7 +2,22 @@ import { Router } from 'express'; import { getDb } from '../db/connection.js'; import { decrypt } from '../services/crypto.js'; import { config } from '../config.js'; -import { proxyRequest } from '../services/proxyService.js'; +import { proxyRequest, ProxyConfig } from '../services/proxyService.js'; + +function getProxyConfig(): ProxyConfig | null { + try { + const db = getDb(); + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('proxy_config') as { value: string } | undefined; + if (!row?.value) return null; + const parsed = JSON.parse(row.value); + if (parsed && parsed.enabled && parsed.host && parsed.port) { + return parsed as ProxyConfig; + } + return null; + } catch { + return null; + } +} const router = Router(); @@ -77,7 +92,8 @@ router.post('/api/proxy/github/*', async (req, res) => { 'User-Agent': 'GithubStarsManager-Backend', }; - const result = await proxyRequest({ url: targetUrl, method, headers }); + const proxyConfig = getProxyConfig(); + const result = await proxyRequest({ url: targetUrl, method, headers, proxyConfig }); res.status(result.status).json(result.data); } catch (err) { console.error('GitHub proxy error:', err); @@ -187,12 +203,14 @@ router.post('/api/proxy/ai', async (req, res) => { const timeout = apiType === 'openai-responses' || !!reasoningEffort ? 600000 : 60000; + const proxyConfig = getProxyConfig(); const result = await proxyRequest({ url: targetUrl, method: 'POST', headers, body: effectiveRequestBody, timeout, + proxyConfig, }); res.status(result.status).json(result.data); @@ -242,12 +260,14 @@ router.post('/api/proxy/webdav', async (req, res) => { headers['Content-Type'] = headers['Content-Type'] || 'application/xml'; } + const proxyConfig = getProxyConfig(); const result = await proxyRequest({ url: targetUrl, method, headers, body: requestBody, timeout: 60000, + proxyConfig, }); res.status(result.status).json(result.data); @@ -288,7 +308,8 @@ router.post('/api/proxy/github/search/repositories', async (req, res) => { 'User-Agent': 'GithubStarsManager-Backend', }; - const result = await proxyRequest({ url: targetUrl, method: 'GET', headers }); + const proxyConfig = getProxyConfig(); + const result = await proxyRequest({ url: targetUrl, method: 'GET', headers, proxyConfig }); res.status(result.status).json(result.data); } catch (err) { console.error('GitHub search repositories proxy error:', err); @@ -327,7 +348,8 @@ router.post('/api/proxy/github/search/users', async (req, res) => { 'User-Agent': 'GithubStarsManager-Backend', }; - const result = await proxyRequest({ url: targetUrl, method: 'GET', headers }); + const proxyConfig = getProxyConfig(); + const result = await proxyRequest({ url: targetUrl, method: 'GET', headers, proxyConfig }); res.status(result.status).json(result.data); } catch (err) { console.error('GitHub search users proxy error:', err); @@ -335,4 +357,80 @@ router.post('/api/proxy/github/search/users', async (req, res) => { } }); +// GET /api/settings/proxy +router.get('/api/settings/proxy', (_req, res) => { + try { + const db = getDb(); + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('proxy_config') as { value: string } | undefined; + if (!row?.value) { + res.json({ enabled: false, type: 'http', host: '', port: 7890 }); + return; + } + const parsed = JSON.parse(row.value); + // Mask password + if (parsed.password) { + parsed.hasPassword = true; + parsed.password = ''; + } + res.json(parsed); + } catch { + res.json({ enabled: false, type: 'http', host: '', port: 7890 }); + } +}); + +// PUT /api/settings/proxy +router.put('/api/settings/proxy', (req, res) => { + try { + const db = getDb(); + const { enabled, type, host, port, username, password } = req.body; + + const configToStore: Record = { enabled, type, host, port, username }; + if (password) { + configToStore.password = password; + } + + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') + .run('proxy_config', JSON.stringify(configToStore)); + + res.json({ success: true }); + } catch (err) { + console.error('Failed to save proxy config:', err); + res.status(500).json({ error: 'Failed to save proxy config' }); + } +}); + +// POST /api/settings/proxy/test +router.post('/api/settings/proxy/test', async (req, res) => { + try { + const { host, port, type } = req.body; + if (!host || !port) { + res.json({ success: false, error: 'Host and port are required' }); + return; + } + + // Test TCP connectivity to the proxy server + const net = await import('net'); + const result = await new Promise<{ success: boolean; error?: string }>((resolve) => { + const socket = new net.Socket(); + socket.setTimeout(5000); + socket.on('connect', () => { + socket.destroy(); + resolve({ success: true }); + }); + socket.on('timeout', () => { + socket.destroy(); + resolve({ success: false, error: 'Connection timeout' }); + }); + socket.on('error', (err: Error) => { + resolve({ success: false, error: err.message }); + }); + socket.connect(port, host); + }); + + res.json(result); + } catch (err) { + res.json({ success: false, error: err instanceof Error ? err.message : 'Unknown error' }); + } +}); + export default router; diff --git a/server/src/services/proxyService.ts b/server/src/services/proxyService.ts index 8a1a17e8..cf04c168 100644 --- a/server/src/services/proxyService.ts +++ b/server/src/services/proxyService.ts @@ -1,9 +1,21 @@ +import axios, { AxiosRequestConfig } from 'axios'; + +export interface ProxyConfig { + enabled: boolean; + type: 'http' | 'socks5'; + host: string; + port: number; + username?: string; + password?: string; +} + export interface ProxyRequestOptions { url: string; method: string; headers?: Record; body?: string | object; timeout?: number; + proxyConfig?: ProxyConfig | null; } export interface ProxyResponse { @@ -57,60 +69,86 @@ function validateUrl(rawUrl: string): void { } export async function proxyRequest(options: ProxyRequestOptions): Promise { - const { url, method, headers = {}, body, timeout = 30000 } = options; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); + const { url, method, headers = {}, body, timeout = 30000, proxyConfig } = options; try { validateUrl(url); console.log(`[Proxy] ${method} ${redactUrl(url)}`); - const fetchOptions: RequestInit = { - method, + const axiosConfig: AxiosRequestConfig = { + url, + method: method.toLowerCase() as AxiosRequestConfig['method'], headers, - signal: controller.signal, + timeout, + validateStatus: () => true, // 不抛出 HTTP 错误状态码 }; if (body && method !== 'GET' && method !== 'HEAD') { - fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body); + axiosConfig.data = body; const hasContentType = Object.keys(headers).some( k => k.toLowerCase() === 'content-type' ); - if (!hasContentType) { - (fetchOptions.headers as Record)['Content-Type'] = 'application/json'; + if (!hasContentType && typeof body === 'object') { + axiosConfig.headers = { ...axiosConfig.headers, 'Content-Type': 'application/json' }; } } - const response = await fetch(url, fetchOptions); + // 配置代理 + if (proxyConfig?.enabled && proxyConfig.host && proxyConfig.port) { + axiosConfig.proxy = { + protocol: proxyConfig.type === 'socks5' ? 'socks5' : 'http', + host: proxyConfig.host, + port: proxyConfig.port, + }; + } + + const response = await axios(axiosConfig); console.log(`[Proxy] ${method} ${redactUrl(url)} -> ${response.status}`); const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); + if (response.headers) { + for (const [key, value] of Object.entries(response.headers)) { + responseHeaders[key] = String(value); + } + } let data: unknown; - const contentType = response.headers.get('content-type') || ''; - const text = await response.text(); - if (contentType.includes('application/json') && text.length > 0) { + const contentType = String(response.headers['content-type'] || ''); + if (contentType.includes('application/json') && typeof response.data === 'object') { + data = response.data; + } else if (typeof response.data === 'string') { try { - data = JSON.parse(text); + data = JSON.parse(response.data); } catch { - data = text; + data = response.data; } } else { - data = text; + data = response.data; } return { status: response.status, headers: responseHeaders, data }; } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - return { status: 504, headers: {}, data: { error: 'Gateway Timeout', code: 'GATEWAY_TIMEOUT' } }; + if (axios.isAxiosError(error)) { + if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) { + return { status: 504, headers: {}, data: { error: 'Gateway Timeout', code: 'GATEWAY_TIMEOUT' } }; + } + if (error.code === 'ECONNREFUSED') { + return { status: 502, headers: {}, data: { error: 'Proxy connection refused', code: 'PROXY_CONNECTION_REFUSED', details: error.message } }; + } + if (error.code === 'ETIMEDOUT') { + return { status: 504, headers: {}, data: { error: 'Proxy connection timeout', code: 'PROXY_TIMEOUT', details: error.message } }; + } + if (error.response) { + // 请求已发出,服务器返回了错误状态码 + return { + status: error.response.status, + headers: {}, + data: error.response.data || { error: 'Upstream error' } + }; + } } console.error(`[Proxy] Error: ${error instanceof Error ? error.message : 'Unknown error'}`); return { status: 502, headers: {}, data: { error: 'Bad Gateway', code: 'BAD_GATEWAY', details: error instanceof Error ? error.message : 'Unknown error' } }; - } finally { - clearTimeout(timeoutId); } } diff --git a/src/components/settings/GeneralPanel.tsx b/src/components/settings/GeneralPanel.tsx index 2acfccc7..9f283af1 100644 --- a/src/components/settings/GeneralPanel.tsx +++ b/src/components/settings/GeneralPanel.tsx @@ -4,6 +4,7 @@ import { UpdateChecker } from '../UpdateChecker'; import { useAppStore } from '../../store/useAppStore'; import { version } from '../../../package.json'; import { PROJECT_REPO_URL } from '../../constants/project'; +import { NetworkPanel } from './NetworkPanel'; interface GeneralPanelProps { t: (zh: string, en: string) => string; @@ -132,6 +133,8 @@ export const GeneralPanel: React.FC = ({ t }) => { + + ); }; diff --git a/src/components/settings/NetworkPanel.tsx b/src/components/settings/NetworkPanel.tsx new file mode 100644 index 00000000..1324e3e3 --- /dev/null +++ b/src/components/settings/NetworkPanel.tsx @@ -0,0 +1,289 @@ +import React, { useState, useEffect } from 'react'; +import { Wifi, Eye, EyeOff, Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { useAppStore } from '../../store/useAppStore'; +import { backend } from '../../services/backendAdapter'; +import { isElectron, electronProxy } from '../../services/electronProxy'; +import type { ProxyConfig, ProxyType } from '../../types'; + +interface NetworkPanelProps { + t: (zh: string, en: string) => string; +} + +export const NetworkPanel: React.FC = ({ t }) => { + const { proxyConfig, setProxyConfig, backendApiSecret } = useAppStore(); + + const [form, setForm] = useState(proxyConfig); + const [showPassword, setShowPassword] = useState(false); + const [showAuth, setShowAuth] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null); + const [saving, setSaving] = useState(false); + + // Sync form when store changes externally + useEffect(() => { + setForm(proxyConfig); + if (proxyConfig.username || proxyConfig.password) { + setShowAuth(true); + } + }, [proxyConfig]); + + const canUseProxy = isElectron() || backend.isAvailable; + + if (!canUseProxy) { + return null; + } + + const handleSave = async () => { + setSaving(true); + try { + // Save to store + setProxyConfig(form); + + // If Electron, sync to Electron main process + if (isElectron()) { + await electronProxy.setProxy(form); + } + + // If backend is available, sync to backend + if (backend.isAvailable) { + try { + const authHeaders: Record = { 'Content-Type': 'application/json' }; + if (backendApiSecret) { + authHeaders['Authorization'] = `Bearer ${backendApiSecret}`; + } + await fetch('/api/settings/proxy', { + method: 'PUT', + headers: authHeaders, + body: JSON.stringify(form), + }); + } catch { + // Backend sync is best-effort + } + } + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + setTesting(true); + setTestResult(null); + try { + if (isElectron()) { + const result = await electronProxy.testProxy(form); + setTestResult(result); + } else if (backend.isAvailable) { + try { + const authHeaders: Record = { 'Content-Type': 'application/json' }; + if (backendApiSecret) { + authHeaders['Authorization'] = `Bearer ${backendApiSecret}`; + } + const resp = await fetch('/api/settings/proxy/test', { + method: 'POST', + headers: authHeaders, + body: JSON.stringify(form), + }); + const data = await resp.json(); + setTestResult(data); + } catch (e) { + setTestResult({ success: false, error: e instanceof Error ? e.message : 'Unknown error' }); + } + } + } finally { + setTesting(false); + } + }; + + const hasChanges = JSON.stringify(form) !== JSON.stringify(proxyConfig); + + return ( +
+
+
+ +

+ {t('网络代理', 'Network Proxy')} +

+
+ +
+ + {form.enabled && ( +
+ {/* Proxy Type */} +
+ +
+ {(['http', 'socks5'] as ProxyType[]).map((type) => ( + + ))} +
+
+ + {/* Host and Port */} +
+
+ + setForm({ ...form, host: e.target.value })} + placeholder="127.0.0.1" + className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> +
+
+ + setForm({ ...form, port: parseInt(e.target.value) || 0 })} + placeholder="7890" + min={1} + max={65535} + className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> +
+
+ + {/* Authentication (collapsible) */} +
+ + + {showAuth && ( +
+
+ + setForm({ ...form, username: e.target.value || undefined })} + placeholder={t('可选', 'Optional')} + className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> +
+
+ +
+ setForm({ ...form, password: e.target.value || undefined })} + placeholder={t('可选', 'Optional')} + className="w-full px-3 py-2 pr-10 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> + +
+
+
+ )} +
+ + {/* Actions */} +
+ + + +
+ + {/* Test Result */} + {testResult && ( +
+ {testResult.success ? ( + + ) : ( + + )} + + {testResult.success + ? t('代理连接成功', 'Proxy connection successful') + : testResult.error || t('代理连接失败', 'Proxy connection failed')} + +
+ )} +
+ )} +
+ ); +}; diff --git a/src/services/electronProxy.ts b/src/services/electronProxy.ts new file mode 100644 index 00000000..3421691d --- /dev/null +++ b/src/services/electronProxy.ts @@ -0,0 +1,36 @@ +import type { ProxyConfig } from '../types'; + +interface ElectronAPI { + setProxy: (config: ProxyConfig) => Promise<{ success: boolean }>; + getProxy: () => Promise; + testProxy: (config: ProxyConfig) => Promise<{ success: boolean; error?: string }>; +} + +declare global { + interface Window { + electronAPI?: ElectronAPI; + } +} + +export const isElectron = (): boolean => { + return typeof window !== 'undefined' && !!window.electronAPI; +}; + +export const electronProxy = { + async setProxy(config: ProxyConfig): Promise { + if (window.electronAPI) { + await window.electronAPI.setProxy(config); + } + }, + + async getProxy(): Promise { + return window.electronAPI?.getProxy() ?? null; + }, + + async testProxy(config: ProxyConfig): Promise<{ success: boolean; error?: string }> { + if (!window.electronAPI) { + return { success: false, error: 'Not running in Electron' }; + } + return window.electronAPI.testProxy(config); + }, +}; diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 618c552f..3cb76589 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -5,16 +5,17 @@ import { Repository, Release, ForkRepo, - AIConfig, - WebDAVConfig, - SearchFilters, - GitHubUser, - Category, - AssetFilter, - UpdateNotification, - AnalysisProgress, - DiscoveryChannel, - DiscoveryChannelId, + AIConfig, + WebDAVConfig, + ProxyConfig, + SearchFilters, + GitHubUser, + Category, + AssetFilter, + UpdateNotification, + AnalysisProgress, + DiscoveryChannel, + DiscoveryChannelId, DiscoveryRepo, DiscoveryPlatform, ProgrammingLanguage, @@ -167,6 +168,9 @@ interface AppActions { // Backend actions setBackendApiSecret: (secret: string | null) => void; + // Proxy actions + setProxyConfig: (updates: Partial) => void; + // Release Timeline View actions setReleaseViewMode: (mode: 'timeline' | 'repository') => void; setReleaseSelectedFilters: (filters: string[]) => void; @@ -271,6 +275,7 @@ type PersistedAppState = Partial< | 'discoveryLanguage' | 'discoverySortBy' | 'discoverySortOrder' + | 'proxyConfig' | 'subscriptionRepos' | 'subscriptionLastRefresh' | 'subscriptionIsLoading' @@ -523,6 +528,13 @@ const normalizePersistedState = ( defaultSubscriptionChannels.filter(dch => !persisted.some((ch: unknown) => (ch as Record).id === dch.id)) ); })(), + proxyConfig: (() => { + const p = (safePersisted as Record).proxyConfig; + if (p && typeof p === 'object' && typeof (p as Record).enabled === 'boolean') { + return p as import('../types').ProxyConfig; + } + return { enabled: false, type: 'http' as const, host: '', port: 7890 }; + })(), }; }; @@ -710,6 +722,7 @@ export const useAppStore = create()( updateNotification: null, analysisProgress: { current: 0, total: 0 }, backendApiSecret: readSessionBackendSecret(), + proxyConfig: { enabled: false, type: 'http', host: '', port: 7890 }, isSidebarCollapsed: false, readmeModalOpen: false, releaseViewMode: 'timeline', @@ -1234,6 +1247,9 @@ export const useAppStore = create()( writeSessionBackendSecret(backendApiSecret); set({ backendApiSecret }); }, + setProxyConfig: (updates) => set((state) => ({ + proxyConfig: { ...state.proxyConfig, ...updates } + })), // Release Timeline View actions setReleaseViewMode: (releaseViewMode) => set({ releaseViewMode }), @@ -1389,7 +1405,7 @@ export const useAppStore = create()( }), { name: 'github-stars-manager', - version: 5, + version: 6, storage: debouncedPersistStorage, partialize: (state) => ({ // 持久化用户信息和认证状态 @@ -1474,6 +1490,7 @@ export const useAppStore = create()( discoverySortBy: state.discoverySortBy, discoverySortOrder: state.discoverySortOrder, discoverySelectedTopic: state.discoverySelectedTopic, + proxyConfig: state.proxyConfig, }), migrate: (persistedState) => { // 版本升级适配处理 @@ -1595,6 +1612,11 @@ export const useAppStore = create()( }; } + // v5→v6: 初始化 proxyConfig + if (state && !(state as Record).proxyConfig) { + (state as Record).proxyConfig = { enabled: false, type: 'http', host: '', port: 7890 }; + } + return state as PersistedAppState; }, merge: (persistedState, currentState) => { diff --git a/src/types/index.ts b/src/types/index.ts index 0733a6eb..c511ce35 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -158,6 +158,17 @@ export interface WebDAVConfig { passwordStatus?: SecretStatus; } +export type ProxyType = 'http' | 'socks5'; + +export interface ProxyConfig { + enabled: boolean; + type: ProxyType; + host: string; + port: number; + username?: string; + password?: string; +} + export interface SearchFilters { query: string; tags: string[]; @@ -248,6 +259,9 @@ export interface AppState { // Backend backendApiSecret: string | null; + // Network Proxy + proxyConfig: ProxyConfig; + // Fork Timeline View forks: ForkRepo[]; readForks: Set; From 950a9df3c925ea26b7199c4f9489899e4c37e2bf Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Tue, 26 May 2026 20:14:00 +0800 Subject: [PATCH 02/12] fix: address code review - YAML syntax, proxy auth, SOCKS5, a11y, validation - Fix workflow YAML syntax: replace template literal with string concatenation for preload.js generation (backtick broke YAML parsing) - Fix proxyService: use socks-proxy-agent for SOCKS5, set proxy.auth for HTTP proxy credentials, set proxy=false when disabled to prevent env fallback - Fix NetworkPanel: add aria-label on toggle switch and password button, add form validation (host required, port 1-65535), remote-first save order - Fix store: validate full ProxyConfig shape in normalizePersistedState - Fix proxy.ts PUT: preserve existing password when request omits it - Add socks-proxy-agent dependency for SOCKS5 support Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build-desktop.yml | 15 ++--- server/package-lock.json | 82 +++++++++++++++++++++++- server/package.json | 3 +- server/src/routes/proxy.ts | 14 ++++ server/src/services/proxyService.ts | 30 +++++++-- src/components/settings/NetworkPanel.tsx | 69 +++++++++++--------- src/store/useAppStore.ts | 13 +++- 7 files changed, 177 insertions(+), 49 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 59eea9d3..37b229b6 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -464,14 +464,13 @@ jobs: fs.writeFileSync('electron/main.js', mainJsContent); - const preloadJsContent = `const { contextBridge, ipcRenderer } = require('electron'); - -contextBridge.exposeInMainWorld('electronAPI', { - setProxy: (config) => ipcRenderer.invoke('set-proxy', config), - getProxy: () => ipcRenderer.invoke('get-proxy'), - testProxy: (config) => ipcRenderer.invoke('test-proxy', config), -}); -`; + const preloadJsContent = 'const { contextBridge, ipcRenderer } = require(\\'electron\\');\\n' + + '\\n' + + 'contextBridge.exposeInMainWorld(\\'electronAPI\\', {\\n' + + ' setProxy: (config) => ipcRenderer.invoke(\\'set-proxy\\', config),\\n' + + ' getProxy: () => ipcRenderer.invoke(\\'get-proxy\\'),\\n' + + ' testProxy: (config) => ipcRenderer.invoke(\\'test-proxy\\', config),\\n' + + '});\\n'; fs.writeFileSync('electron/preload.js', preloadJsContent); const electronPackageJson = { diff --git a/server/package-lock.json b/server/package-lock.json index 649ca987..5658d03e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -13,7 +13,8 @@ "cors": "^2.8.5", "express": "^4.21.0", "helmet": "^7.1.0", - "morgan": "^1.10.0" + "morgan": "^1.10.0", + "socks-proxy-agent": "^9.0.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.8", @@ -2284,6 +2285,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3235,6 +3245,76 @@ "simple-concat": "^1.0.0" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-9.0.0.tgz", + "integrity": "sha512-fFlbMlfsXhK02ZB8aZY7Hwxh/IHBV9b1Oq9bvBk6tkFWXvdAxUgA0wbw/NYR5liU3Y5+KI6U4FH3kYJt9QYv0w==", + "license": "MIT", + "dependencies": { + "agent-base": "8.0.0", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-8.0.0.tgz", + "integrity": "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/server/package.json b/server/package.json index 6f87d6ae..652bf88c 100644 --- a/server/package.json +++ b/server/package.json @@ -16,7 +16,8 @@ "cors": "^2.8.5", "helmet": "^7.1.0", "morgan": "^1.10.0", - "axios": "^1.7.0" + "axios": "^1.7.0", + "socks-proxy-agent": "^9.0.0" }, "devDependencies": { "@types/express": "^4.17.21", diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index 852a360a..91a69e63 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -384,9 +384,23 @@ router.put('/api/settings/proxy', (req, res) => { const db = getDb(); const { enabled, type, host, port, username, password } = req.body; + // Preserve existing password if not provided in the request + let storedPassword: string | undefined; + if (!password) { + const existing = db.prepare('SELECT value FROM settings WHERE key = ?').get('proxy_config') as { value: string } | undefined; + if (existing?.value) { + try { + const parsed = JSON.parse(existing.value); + storedPassword = parsed.password; + } catch { /* ignore */ } + } + } + const configToStore: Record = { enabled, type, host, port, username }; if (password) { configToStore.password = password; + } else if (storedPassword) { + configToStore.password = storedPassword; } db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') diff --git a/server/src/services/proxyService.ts b/server/src/services/proxyService.ts index cf04c168..63ac9b03 100644 --- a/server/src/services/proxyService.ts +++ b/server/src/services/proxyService.ts @@ -95,11 +95,31 @@ export async function proxyRequest(options: ProxyRequestOptions): Promise = ({ t }) => { return null; } + const isFormValid = !form.enabled || (form.host.trim() && form.port >= 1 && form.port <= 65535); + const handleSave = async () => { + if (!isFormValid) return; + setSaving(true); try { - // Save to store - setProxyConfig(form); - - // If Electron, sync to Electron main process + // Sync to Electron first (if applicable) if (isElectron()) { await electronProxy.setProxy(form); } - // If backend is available, sync to backend + // Sync to backend (if applicable) if (backend.isAvailable) { - try { - const authHeaders: Record = { 'Content-Type': 'application/json' }; - if (backendApiSecret) { - authHeaders['Authorization'] = `Bearer ${backendApiSecret}`; - } - await fetch('/api/settings/proxy', { - method: 'PUT', - headers: authHeaders, - body: JSON.stringify(form), - }); - } catch { - // Backend sync is best-effort + const authHeaders: Record = { 'Content-Type': 'application/json' }; + if (backendApiSecret) { + authHeaders['Authorization'] = `Bearer ${backendApiSecret}`; + } + const resp = await fetch('/api/settings/proxy', { + method: 'PUT', + headers: authHeaders, + body: JSON.stringify(form), + }); + if (!resp.ok) { + throw new Error(`Backend returned ${resp.status}`); } } + + // Only persist locally after remote sync succeeds + setProxyConfig(form); + } catch (e) { + setTestResult({ success: false, error: e instanceof Error ? e.message : t('保存失败', 'Save failed') }); } finally { setSaving(false); } @@ -73,22 +78,20 @@ export const NetworkPanel: React.FC = ({ t }) => { const result = await electronProxy.testProxy(form); setTestResult(result); } else if (backend.isAvailable) { - try { - const authHeaders: Record = { 'Content-Type': 'application/json' }; - if (backendApiSecret) { - authHeaders['Authorization'] = `Bearer ${backendApiSecret}`; - } - const resp = await fetch('/api/settings/proxy/test', { - method: 'POST', - headers: authHeaders, - body: JSON.stringify(form), - }); - const data = await resp.json(); - setTestResult(data); - } catch (e) { - setTestResult({ success: false, error: e instanceof Error ? e.message : 'Unknown error' }); + const authHeaders: Record = { 'Content-Type': 'application/json' }; + if (backendApiSecret) { + authHeaders['Authorization'] = `Bearer ${backendApiSecret}`; } + const resp = await fetch('/api/settings/proxy/test', { + method: 'POST', + headers: authHeaders, + body: JSON.stringify(form), + }); + const data = await resp.json(); + setTestResult(data); } + } catch (e) { + setTestResult({ success: false, error: e instanceof Error ? e.message : 'Unknown error' }); } finally { setTesting(false); } @@ -109,6 +112,7 @@ export const NetworkPanel: React.FC = ({ t }) => { type="button" role="switch" aria-checked={form.enabled} + aria-label={t('启用网络代理', 'Enable network proxy')} onClick={() => setForm({ ...form, enabled: !form.enabled })} className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${form.enabled ? 'bg-brand-indigo' : 'bg-gray-300 dark:bg-gray-600'}`} > @@ -219,6 +223,7 @@ export const NetworkPanel: React.FC = ({ t }) => { /> - - ); }; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index d7e71ff4..c975730e 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -5,3 +5,4 @@ export { BackendPanel } from './BackendPanel'; export { CategoryPanel } from './CategoryPanel'; export { GeneralPanel } from './GeneralPanel'; export { DataManagementPanel } from './DataManagementPanel'; +export { NetworkPanel } from './NetworkPanel';