diff --git a/backend/crates/gateway-admin/src/use_case/proxies.rs b/backend/crates/gateway-admin/src/use_case/proxies.rs
index 367472928..691ff161c 100644
--- a/backend/crates/gateway-admin/src/use_case/proxies.rs
+++ b/backend/crates/gateway-admin/src/use_case/proxies.rs
@@ -2,7 +2,11 @@ use std::sync::Arc;
use async_trait::async_trait;
use futures::{StreamExt, stream};
-use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::SnapshotControl};
+use gateway_core::{
+ account::{OutboundProxy, ProviderAccountId},
+ routing::ProviderKind,
+ runtime::SnapshotControl,
+};
use tokio::sync::Semaphore;
use super::{map_store_error, publish_committed};
@@ -54,6 +58,8 @@ pub trait ProxiesService: Send + Sync {
revision: Revision,
context: &MutationContext,
) -> Result;
+ /// 探测未保存的连接地址,不写入代理记录或修改账号绑定。
+ async fn probe(&self, proxy: &OutboundProxy) -> Result;
}
pub(crate) struct DefaultProxiesService {
@@ -214,6 +220,13 @@ impl ProxiesService for DefaultProxiesService {
Ok(result)
}
+ async fn probe(&self, proxy: &OutboundProxy) -> Result {
+ let _permit = self.test_slots.try_acquire().map_err(|_| {
+ AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
+ })?;
+ Ok(self.probe.test(proxy).await)
+ }
+
async fn test(
&self,
id: &str,
diff --git a/backend/crates/gateway-api/src/admin/proxies.rs b/backend/crates/gateway-api/src/admin/proxies.rs
index 5516a030c..c662dcf2b 100644
--- a/backend/crates/gateway-api/src/admin/proxies.rs
+++ b/backend/crates/gateway-api/src/admin/proxies.rs
@@ -8,7 +8,8 @@ use axum::{
use gateway_admin::model::{
PageSize, Revision,
proxies::{
- NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord, UpdateProxy,
+ NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord,
+ ProxyTestResult, UpdateProxy,
},
};
use serde::{Deserialize, Serialize};
@@ -66,6 +67,12 @@ struct IdRequest {
revision: u64,
}
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+struct ProbeRequest {
+ proxy_url: AccountProxyUpdate,
+}
+
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyTestView {
@@ -75,6 +82,17 @@ struct ProxyTestView {
message: String,
}
+impl From for ProxyTestView {
+ fn from(result: ProxyTestResult) -> Self {
+ Self {
+ success: result.success,
+ latency_ms: result.latency_ms,
+ exit_ip: result.exit_ip.map(|ip| ip.to_string()),
+ message: result.message,
+ }
+ }
+}
+
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyAccountView {
@@ -115,12 +133,7 @@ impl From for ProxyView {
revision: record.revision.get(),
account_count: record.account_count,
last_test_at: record.last_test_at.map(|at| at.to_rfc3339()),
- last_test: record.last_test.map(|result| ProxyTestView {
- success: result.success,
- latency_ms: result.latency_ms,
- exit_ip: result.exit_ip.map(|ip| ip.to_string()),
- message: result.message,
- }),
+ last_test: record.last_test.map(Into::into),
created_at: record.created_at.to_rfc3339(),
updated_at: record.updated_at.to_rfc3339(),
}
@@ -170,6 +183,7 @@ where
.route("/api/admin/proxies/update", post(update::))
.route("/api/admin/proxies/delete", post(delete::))
.route("/api/admin/proxies/test", post(test::))
+ .route("/api/admin/proxies/probe", post(probe::))
}
fn revision(value: u64) -> Result {
@@ -389,6 +403,30 @@ where
))
}
+async fn probe(
+ _: AdminAuth,
+ State(state): State,
+ AdminJson(request): AdminJson,
+) -> Result
+where
+ S: AdminSessionState + Send + Sync,
+{
+ let proxy = request
+ .proxy_url
+ .0
+ .ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
+ let result = state
+ .admin_services()
+ .proxies()
+ .probe(&proxy)
+ .await
+ .map_err(map_error)?;
+ Ok(AdminResponse::new(
+ StatusCode::OK,
+ AdminEnvelope::ok(ProxyTestView::from(result)),
+ ))
+}
+
async fn test(
auth: AdminAuth,
State(state): State,
diff --git a/backend/crates/gateway-api/tests/admin/proxies.rs b/backend/crates/gateway-api/tests/admin/proxies.rs
index dda99ec53..fc25d2a6c 100644
--- a/backend/crates/gateway-api/tests/admin/proxies.rs
+++ b/backend/crates/gateway-api/tests/admin/proxies.rs
@@ -304,6 +304,67 @@ async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_creden
assert_eq!(listed["data"]["page"]["total"], 0);
}
+#[tokio::test]
+async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records() {
+ let fixture = AdminTestFixture::new().await;
+ fixture.auth.insert_session("valid-session");
+ let draft = json!({
+ "proxyUrl": "http://test-user:private-password@proxy.example:8080"
+ });
+ let (status, probed) = request(
+ &fixture,
+ "/api/admin/proxies/probe",
+ Some(draft.clone()),
+ true,
+ )
+ .await;
+ assert_eq!(status, StatusCode::OK);
+ assert_eq!(
+ probed["data"],
+ json!({"success": true, "latencyMs": 15, "exitIp": "203.0.113.2", "message": "Connected"})
+ );
+ let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
+ assert_eq!(listed["data"]["page"]["total"], 0);
+
+ let (_, created) = request(
+ &fixture,
+ "/api/admin/proxies/create",
+ Some(json!({"name": "Saved", "proxyUrl": "http://saved.example:8080"})),
+ true,
+ )
+ .await;
+ let (status, _) = request(&fixture, "/api/admin/proxies/probe", Some(draft), true).await;
+ assert_eq!(status, StatusCode::OK);
+ let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
+ assert_eq!(listed["data"]["items"][0], created["data"]["record"]);
+}
+
+#[tokio::test]
+async fn proxy_probe_rejects_empty_or_invalid_addresses() {
+ let fixture = AdminTestFixture::new().await;
+ fixture.auth.insert_session("valid-session");
+ for (body, expected) in [
+ (json!({"proxyUrl": ""}), StatusCode::BAD_REQUEST),
+ (json!({"proxyUrl": null}), StatusCode::UNPROCESSABLE_ENTITY),
+ (json!({}), StatusCode::UNPROCESSABLE_ENTITY),
+ (
+ json!({"proxyUrl": "ftp://test-user:private-password@proxy.example"}),
+ StatusCode::UNPROCESSABLE_ENTITY,
+ ),
+ (
+ json!({"proxyUrl": "http://proxy.example:8080", "name": "Not saved"}),
+ StatusCode::UNPROCESSABLE_ENTITY,
+ ),
+ ] {
+ assert_eq!(
+ request(&fixture, "/api/admin/proxies/probe", Some(body), true)
+ .await
+ .0,
+ expected
+ );
+ }
+}
+
#[tokio::test]
async fn proxy_routes_require_auth_and_reject_invalid_input() {
let fixture = AdminTestFixture::new().await;
@@ -355,7 +416,14 @@ async fn proxy_routes_require_auth_and_reject_invalid_input() {
.0,
StatusCode::NOT_FOUND
);
- for action in ["create", "update", "delete", "test", "accounts/remove"] {
+ for action in [
+ "create",
+ "update",
+ "delete",
+ "test",
+ "probe",
+ "accounts/remove",
+ ] {
assert_eq!(
request(
&fixture,
diff --git a/frontend/src/api/modules/proxies.ts b/frontend/src/api/modules/proxies.ts
index 1380685b0..e9b299fe5 100644
--- a/frontend/src/api/modules/proxies.ts
+++ b/frontend/src/api/modules/proxies.ts
@@ -104,3 +104,12 @@ export function testProxy(data: { id: string, revision: number }) {
timeout: 25000,
})
}
+
+export function probeProxy(data: { proxyUrl: string }) {
+ return request({
+ url: '/api/admin/proxies/probe',
+ method: 'POST',
+ data,
+ timeout: 25000,
+ })
+}
diff --git a/frontend/src/components/base/BaseTable/columns.ts b/frontend/src/components/base/BaseTable/columns.ts
index 1728557b8..849f3a929 100644
--- a/frontend/src/components/base/BaseTable/columns.ts
+++ b/frontend/src/components/base/BaseTable/columns.ts
@@ -24,6 +24,7 @@ export interface BaseTableColumn {
label?: string
kind?: TableColumnKind
size?: TableColumnSize
+ fixedWidth?: boolean
align?: TableColumnAlign
sortable?: boolean | string
format?: (value: unknown, row: Row) => unknown
@@ -212,11 +213,14 @@ export function columnStyle(
column: ResolvedTableColumn,
columns: ResolvedTableColumn[],
) {
- const tableWidth = minimumTableWidth(columns)
- const widthPercent = tableWidth > 0 ? (column.basisWidth / tableWidth) * 100 : 0
+ const fixedWidth = columns.reduce((total, item) => total + (item.fixedWidth ? item.basisWidth : 0), 0)
+ const flexibleWidth = minimumTableWidth(columns) - fixedWidth
+ const ratio = flexibleWidth > 0 ? column.basisWidth / flexibleWidth : 0
return {
- width: `${widthPercent}%`,
+ width: column.fixedWidth
+ ? `${column.basisWidth}px`
+ : `${ratio * 100}%`,
minWidth: `${column.basisWidth}px`,
}
}
diff --git a/frontend/src/views/proxies/components/ProxyFormModal.vue b/frontend/src/views/proxies/components/ProxyFormModal.vue
index 82dfd1f51..f64ee0585 100644
--- a/frontend/src/views/proxies/components/ProxyFormModal.vue
+++ b/frontend/src/views/proxies/components/ProxyFormModal.vue
@@ -12,14 +12,17 @@ import BaseModal from '@/components/base/BaseModal/index.vue'
const props = defineProps<{
proxy: OutboundProxyRecord | null
saving: boolean
+ testing: boolean
}>()
const emit = defineEmits<{
- save: [testAfter: boolean]
+ save: []
+ test: []
}>()
const open = defineModel({ required: true })
const name = defineModel('name', { required: true })
const proxyUrl = defineModel('proxyUrl', { required: true })
const showSecret = shallowRef(false)
+const busy = computed(() => props.saving || props.testing)
const title = computed(() => props.proxy ? '编辑代理' : '新增代理')
const connectionDescription = computed(() => props.proxy
? '留空保留当前连接和认证信息;填写新地址时,请包含所需的用户名和密码。'
@@ -31,22 +34,22 @@ watch(open, () => {
-
+
-
+
-
+
@@ -58,21 +61,21 @@ watch(open, () => {
-
+
取消
-
+
+
+
+
+ 测试连接
+
+
保存代理
-
-
-
-
- 保存并测试
-
diff --git a/frontend/src/views/proxies/index.vue b/frontend/src/views/proxies/index.vue
index 62cba4a0a..8a14ee882 100644
--- a/frontend/src/views/proxies/index.vue
+++ b/frontend/src/views/proxies/index.vue
@@ -1,9 +1,9 @@