From 088d0455a90b09856df06e19cd7149c5ecf480cb Mon Sep 17 00:00:00 2001 From: Brucents Date: Fri, 20 Mar 2026 19:13:23 +0800 Subject: [PATCH] fix(proxy): add system proxy connectivity test and align proxy UI copy --- src/server/proxy-core/surfaces/chatSurface.ts | 3 +- .../surfaces/openAiResponsesSurface.ts | 3 +- .../api/settings.system-proxy-test.test.ts | 123 ++++++++++++++++ src/server/routes/api/settings.ts | 134 +++++++++++++++++- .../routes/proxy/chat.codex-oauth.test.ts | 80 +++++++++++ .../proxy/responses.codex-oauth.test.ts | 63 ++++++++ src/web/api.ts | 20 +++ src/web/pages/Settings.tsx | 66 ++++++++- src/web/pages/Sites.tsx | 4 +- src/web/pages/settings.system-proxy.test.tsx | 52 +++++++ 10 files changed, 539 insertions(+), 9 deletions(-) create mode 100644 src/server/routes/api/settings.system-proxy-test.test.ts diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index b69d6f3..5c5c6b6 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -24,6 +24,7 @@ import { import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; +import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; @@ -241,7 +242,7 @@ export async function handleChatSurfaceRequest( extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, }; const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); - const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`; + const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path); const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl); if (refreshedResponse.ok) { return { diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index d824ca3..a23d08e 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -19,6 +19,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; +import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; @@ -341,7 +342,7 @@ export async function handleOpenAiResponsesSurfaceRequest( extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, }; const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); - const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`; + const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path); const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl); if (refreshedResponse.ok) { return { diff --git a/src/server/routes/api/settings.system-proxy-test.test.ts b/src/server/routes/api/settings.system-proxy-test.test.ts new file mode 100644 index 0000000..0eaaa8c --- /dev/null +++ b/src/server/routes/api/settings.system-proxy-test.test.ts @@ -0,0 +1,123 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const fetchMock = vi.fn(); + +vi.mock('undici', async () => { + const actual = await vi.importActual('undici'); + return { + ...actual, + fetch: (...args: unknown[]) => fetchMock(...args), + }; +}); + +type ConfigModule = typeof import('../../config.js'); + +describe('settings system proxy test route', () => { + let app: FastifyInstance; + let config: ConfigModule['config']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-system-proxy-test-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const configModule = await import('../../config.js'); + const settingsRoutesModule = await import('./settings.js'); + + config = configModule.config; + + app = Fastify(); + await app.register(settingsRoutesModule.settingsRoutes); + }); + + beforeEach(() => { + fetchMock.mockReset(); + config.systemProxyUrl = ''; + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('tests the provided system proxy url and returns latency', async () => { + fetchMock.mockResolvedValue(new Response(null, { + status: 204, + headers: { 'content-type': 'text/plain' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: { + proxyUrl: 'http://127.0.0.1:7890', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + proxyUrl: 'http://127.0.0.1:7890', + probeUrl: 'https://www.gstatic.com/generate_204', + finalUrl: 'https://www.gstatic.com/generate_204', + reachable: true, + ok: true, + statusCode: 204, + }); + expect((response.json() as { latencyMs?: number }).latencyMs).toBeGreaterThanOrEqual(1); + + const [url, requestInit] = fetchMock.mock.calls[0] as [string, Record]; + expect(url).toBe('https://www.gstatic.com/generate_204'); + expect(requestInit.method).toBe('GET'); + expect(requestInit.dispatcher).toBeTruthy(); + }); + + it('uses the saved system proxy url when request body is empty', async () => { + config.systemProxyUrl = 'socks5://127.0.0.1:1080'; + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: {}, + }); + + expect(response.statusCode).toBe(200); + expect((response.json() as { proxyUrl?: string }).proxyUrl).toBe('socks5://127.0.0.1:1080'); + }); + + it('rejects missing system proxy url', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: {}, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('请先填写系统代理地址'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 502 when the proxy probe request fails', async () => { + fetchMock.mockRejectedValue(new TypeError('fetch failed', { + cause: new Error('connect ECONNREFUSED 127.0.0.1:7890'), + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: { + proxyUrl: 'http://127.0.0.1:7890', + }, + }); + + expect(response.statusCode).toBe(502); + expect((response.json() as { message?: string }).message).toContain('连接被拒绝'); + expect((response.json() as { message?: string }).message).not.toContain('fetch failed'); + }); +}); diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index d740fb0..d39b142 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from 'fastify'; import cron from 'node-cron'; +import { fetch } from 'undici'; import { config } from '../../config.js'; import { db, runtimeDbDialect, schema } from '../../db/index.js'; import { upsertSetting } from '../../db/upsertSetting.js'; @@ -17,7 +18,7 @@ import { } from '../../services/databaseMigrationService.js'; import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js'; import { extractClientIp, isIpAllowed } from '../../middleware/auth.js'; -import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js'; +import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyRequestInit } from '../../services/siteProxy.js'; import { performFactoryReset } from '../../services/factoryResetService.js'; import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js'; import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js'; @@ -70,6 +71,10 @@ interface DatabaseMigrationBody { ssl?: unknown; } +interface SystemProxyTestBody { + proxyUrl?: unknown; +} + type RuntimeDatabaseConfig = { dialect: MigrationDialect; connectionString: string; @@ -80,6 +85,8 @@ const PROXY_TOKEN_PREFIX = 'sk-'; const DB_TYPE_SETTING_KEY = 'db_type'; const DB_URL_SETTING_KEY = 'db_url'; const DB_SSL_SETTING_KEY = 'db_ssl'; +const SYSTEM_PROXY_TEST_PROBE_URL = 'https://www.gstatic.com/generate_204'; +const SYSTEM_PROXY_TEST_TIMEOUT_MS = 15_000; function isValidProxyToken(value: string): boolean { return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6; @@ -118,6 +125,94 @@ function toPositiveNumberOrFallback(value: unknown, fallback: number) { return n; } +function extractNestedErrorMessages(error: unknown): string[] { + const messages: string[] = []; + const visited = new Set(); + let current: any = error; + + while (current && !visited.has(current)) { + visited.add(current); + const message = typeof current?.message === 'string' ? current.message.trim() : ''; + if (message) { + messages.push(message); + } + current = current?.cause; + } + + return messages; +} + +function describeSystemProxyTestFailure(error: unknown): string { + const messages = extractNestedErrorMessages(error); + const detail = messages.find((message) => message && message !== 'fetch failed') + || messages[0] + || '未知错误'; + + if (/ECONNREFUSED/i.test(detail)) { + return '系统代理测试失败:连接被拒绝,请检查代理地址、端口和本地代理程序是否已启动'; + } + + if (/ETIMEDOUT|timed out|timeout/i.test(detail)) { + return '系统代理测试失败:连接超时,请检查代理服务或当前网络是否可用'; + } + + if (/ENOTFOUND|EAI_AGAIN/i.test(detail)) { + return '系统代理测试失败:域名解析失败,请检查网络或代理的 DNS 配置'; + } + + if (/ECONNRESET/i.test(detail)) { + return '系统代理测试失败:连接被对端重置,请检查代理链路是否稳定'; + } + + if (/407/.test(detail) || /proxy authentication/i.test(detail)) { + return '系统代理测试失败:代理要求认证,请检查用户名、密码或代理配置'; + } + + return `系统代理测试失败:${detail}`; +} + +async function testSystemProxyConnectivity(proxyUrl: string) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SYSTEM_PROXY_TEST_TIMEOUT_MS); + const startedAt = Date.now(); + + try { + const response = await fetch( + SYSTEM_PROXY_TEST_PROBE_URL, + withExplicitProxyRequestInit(proxyUrl, { + method: 'GET', + signal: controller.signal, + headers: { + 'cache-control': 'no-cache', + 'user-agent': 'metapi-system-proxy-tester/1.0', + }, + }), + ); + + try { + await response.arrayBuffer(); + } catch { + // Ignore body drain failures; reachability is determined by receiving a response. + } + + return { + reachable: true, + ok: response.ok, + statusCode: response.status, + latencyMs: Math.max(1, Date.now() - startedAt), + probeUrl: SYSTEM_PROXY_TEST_PROBE_URL, + finalUrl: response.url || SYSTEM_PROXY_TEST_PROBE_URL, + }; + } catch (error: any) { + if (error?.name === 'AbortError') { + throw new Error(`系统代理测试超时(${Math.round(SYSTEM_PROXY_TEST_TIMEOUT_MS / 1000)}s)`); + } + throw new Error(describeSystemProxyTestFailure(error)); + } finally { + clearTimeout(timeout); + } +} + function toStringList(value: unknown): string[] { if (Array.isArray(value)) { return value @@ -524,6 +619,43 @@ export async function settingsRoutes(app: FastifyInstance) { return getRuntimeSettingsResponse(currentAdminIp); }); + app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => { + const rawProxyUrl = request.body?.proxyUrl === undefined + ? config.systemProxyUrl + : String(request.body.proxyUrl || '').trim(); + const normalizedProxyUrl = rawProxyUrl + ? normalizeSiteProxyUrl(rawProxyUrl) + : ''; + + if (!rawProxyUrl) { + return reply.code(400).send({ + success: false, + message: '请先填写系统代理地址', + }); + } + + if (!normalizedProxyUrl) { + return reply.code(400).send({ + success: false, + message: '系统代理地址无效,请填写合法的 http(s)/socks 代理 URL', + }); + } + + try { + const result = await testSystemProxyConnectivity(normalizedProxyUrl); + return { + success: true, + proxyUrl: normalizedProxyUrl, + ...result, + }; + } catch (error: any) { + return reply.code(502).send({ + success: false, + message: error?.message || '系统代理测试失败', + }); + } + }); + app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => { const body = request.body || {}; const changedLabels: string[] = []; diff --git a/src/server/routes/proxy/chat.codex-oauth.test.ts b/src/server/routes/proxy/chat.codex-oauth.test.ts index 2f538ee..f561f87 100644 --- a/src/server/routes/proxy/chat.codex-oauth.test.ts +++ b/src/server/routes/proxy/chat.codex-oauth.test.ts @@ -14,6 +14,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => estimatedCostFromQuota: 0, recoveredFromSelfLog: false, })); +const refreshOauthAccessTokenSingleflightMock = vi.fn(); const dbInsertMock = vi.fn((_arg?: any) => ({ values: () => ({ run: () => undefined, @@ -60,6 +61,10 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({ resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), })); +vi.mock('../../services/oauth/refreshSingleflight.js', () => ({ + refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args), +})); + vi.mock('../../db/index.js', () => ({ db: { insert: (arg: any) => dbInsertMock(arg), @@ -107,6 +112,7 @@ describe('chat proxy codex oauth compatibility', () => { reportProxyAllFailedMock.mockReset(); reportTokenExpiredMock.mockReset(); resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + refreshOauthAccessTokenSingleflightMock.mockReset(); dbInsertMock.mockClear(); selectChannelMock.mockReturnValue({ @@ -130,6 +136,11 @@ describe('chat proxy codex oauth compatibility', () => { actualModel: 'gpt-5.4', }); selectNextChannelMock.mockReturnValue(null); + refreshOauthAccessTokenSingleflightMock.mockResolvedValue({ + accessToken: 'fresh-access-token', + accountId: 33, + accountKey: 'chatgpt-account-123', + }); }); afterAll(async () => { @@ -281,4 +292,73 @@ describe('chat proxy codex oauth compatibility', () => { expect(response.body).not.toContain('event: response.created'); expect(response.body).not.toContain('"type":"response.output_text.delta"'); }); + + it('retries oauth chat requests with a normalized upstream URL after refresh', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'openai-site', url: 'https://gateway.example.com/v1/', platform: 'openai' }, + account: { + id: 33, + username: 'oauth-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'oauth-user@example.com', + planType: 'plus', + }, + }), + }, + tokenName: 'default', + tokenValue: 'expired-access-token', + actualModel: 'gpt-4o-mini', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'expired token', type: 'invalid_request_error' }, + }), { + status: 401, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'chatcmpl_refreshed', + object: 'chat.completion', + created: 1706000000, + model: 'gpt-4o-mini', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'ok after refresh' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello oauth' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + expect(firstUrl).toBe('https://gateway.example.com/v1/responses'); + expect(secondUrl).toBe('https://gateway.example.com/v1/responses'); + expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token'); + expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token'); + expect(response.json()?.choices?.[0]?.message?.content).toBe('ok after refresh'); + }); }); diff --git a/src/server/routes/proxy/responses.codex-oauth.test.ts b/src/server/routes/proxy/responses.codex-oauth.test.ts index 5fed1c6..13187a6 100644 --- a/src/server/routes/proxy/responses.codex-oauth.test.ts +++ b/src/server/routes/proxy/responses.codex-oauth.test.ts @@ -214,6 +214,69 @@ describe('responses proxy codex oauth refresh', () => { expect(response.json()?.output_text).toContain('ok after codex token refresh'); }); + it('retries oauth responses requests with a normalized upstream URL after refresh', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'openai-site', url: 'https://gateway.example.com/v1/', platform: 'openai' }, + account: { + id: 33, + username: 'oauth-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'oauth-user@example.com', + planType: 'plus', + }, + }), + }, + tokenName: 'default', + tokenValue: 'expired-access-token', + actualModel: 'gpt-4.1-mini', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'expired token', type: 'invalid_request_error' }, + }), { + status: 401, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_openai_refreshed', + object: 'response', + model: 'gpt-4.1-mini', + status: 'completed', + output_text: 'ok after refresh', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-4.1-mini', + input: 'hello oauth', + }, + }); + + expect(response.statusCode).toBe(200); + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + expect(firstUrl).toBe('https://gateway.example.com/v1/responses'); + expect(secondUrl).toBe('https://gateway.example.com/v1/responses'); + expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token'); + expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token'); + expect(response.json()?.output_text).toBe('ok after refresh'); + }); + it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ id: 'resp_codex_no_system', diff --git a/src/web/api.ts b/src/web/api.ts index a2e1e0b..abccbef 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -156,6 +156,21 @@ export type ProxyTestJobResponse = { expiresAt?: string; }; +export type SystemProxyTestRequest = { + proxyUrl?: string; +}; + +export type SystemProxyTestResponse = { + success: true; + proxyUrl: string; + probeUrl: string; + finalUrl: string; + reachable: true; + ok: boolean; + statusCode: number; + latencyMs: number; +}; + export type ProxyLogStatusFilter = 'all' | 'success' | 'failed'; export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null; @@ -511,6 +526,11 @@ export const api = { method: 'PUT', body: JSON.stringify(data), }), + testSystemProxy: (data: SystemProxyTestRequest) => request('/api/settings/system-proxy/test', { + method: 'POST', + body: JSON.stringify(data), + timeoutMs: 20_000, + }), getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'), updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) => request('/api/settings/database/runtime', { diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index c378e32..b15f20f 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -55,6 +55,11 @@ type RuntimeSettings = { currentAdminIp?: string; }; +type SystemProxyTestState = + | { kind: 'success'; text: string } + | { kind: 'error'; text: string } + | null; + type DownstreamApiKeyItem = { id: number; name: string; @@ -222,6 +227,8 @@ export default function Settings() { const [testingCheckin, setTestingCheckin] = useState(false); const [savingToken, setSavingToken] = useState(false); const [savingSystemProxy, setSavingSystemProxy] = useState(false); + const [testingSystemProxy, setTestingSystemProxy] = useState(false); + const [systemProxyTestState, setSystemProxyTestState] = useState(null); const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); const [savingRouting, setSavingRouting] = useState(false); const [showAdvancedRouting, setShowAdvancedRouting] = useState(false); @@ -576,6 +583,31 @@ export default function Settings() { } }; + const testSystemProxy = async () => { + const proxyUrl = runtime.systemProxyUrl.trim(); + if (!proxyUrl) { + const message = '请先填写系统代理地址'; + setSystemProxyTestState({ kind: 'error', text: message }); + toast.info(message); + return; + } + + setTestingSystemProxy(true); + setSystemProxyTestState(null); + try { + const res = await api.testSystemProxy({ proxyUrl }); + const summary = `连通成功,延迟 ${res.latencyMs} ms`; + setSystemProxyTestState({ kind: 'success', text: summary }); + toast.success(`系统代理测试成功(${res.latencyMs} ms)`); + } catch (err: any) { + const message = err?.message || '系统代理测试失败'; + setSystemProxyTestState({ kind: 'error', text: message }); + toast.error(message); + } finally { + setTestingSystemProxy(false); + } + }; + const saveProxyFailureRules = async () => { setSavingProxyFailureRules(true); try { @@ -1114,13 +1146,39 @@ export default function Settings() { setRuntime((prev) => ({ ...prev, systemProxyUrl: e.target.value }))} + onChange={(e) => { + setRuntime((prev) => ({ ...prev, systemProxyUrl: e.target.value })); + setSystemProxyTestState(null); + }} placeholder="系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)" style={{ ...inputStyle, fontFamily: 'var(--font-mono)', marginBottom: 10 }} /> - +
+ + +
+ {systemProxyTestState && ( +
+ {systemProxyTestState.text} +
+ )}
diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index cfd5fdf..d4b0968 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -921,13 +921,13 @@ export default function Sites() { )}
setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} style={formInputStyle} />
- 留空则直连;填写后优先于系统代理。 + 填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。