feat(proxies): paginate linked accounts and support removal

Load linked accounts on demand with shared plan metadata and guarded
removal to direct connections. Preserve credentials, scheduling settings,
and groups when clearing a proxy binding.

Align proxy forms and tables with shared components, integrate endpoint
details into outbound selectors, and consolidate probe results into the
latency column.

Validation: 11 proxy regressions, strict workspace Clippy, rustfmt,
frontend lint/type checks/build, and local browser checks passed.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-11 14:43:31 +08:00
co-authored by Codex
parent 840707e5a1
commit 029f3124bf
21 changed files with 1516 additions and 208 deletions
+1
View File
@@ -301,6 +301,7 @@ pub async fn initialize(
store.proxies(),
proxy_probe,
snapshot.clone(),
registry.clone(),
)),
auth,
accounts,
@@ -3,7 +3,7 @@
use chrono::{DateTime, Utc};
use gateway_core::account::OutboundProxy;
use super::{PageSize, Revision};
use super::{PageSize, Revision, account_groups::AccountGroupRef};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountProxySelection {
@@ -29,6 +29,30 @@ pub struct ProxyListQuery {
pub struct ProxyAccountRef {
pub id: String,
pub name: String,
pub email: Option<String>,
pub provider_kind: String,
pub authentication_kind: String,
pub plan_type: Option<String>,
pub plan_type_display: Option<String>,
pub groups: Vec<AccountGroupRef>,
pub enabled: bool,
}
/// 按代理查询关联账号,分页与搜索均在存储层执行。
#[derive(Debug, Clone)]
pub struct ProxyAccountListQuery {
pub proxy_id: String,
pub page: u32,
pub page_size: PageSize,
pub search: String,
}
#[derive(Debug, Clone)]
pub struct ProxyAccountPage {
pub items: Vec<ProxyAccountRef>,
pub total: u64,
pub page: u32,
pub page_size: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -45,7 +69,7 @@ pub struct ProxyRecord {
pub name: String,
pub proxy: OutboundProxy,
pub revision: Revision,
pub accounts: Vec<ProxyAccountRef>,
pub account_count: u64,
pub last_test_at: Option<DateTime<Utc>>,
pub last_test: Option<ProxyTestResult>,
pub created_at: DateTime<Utc>,
@@ -252,6 +252,19 @@ impl ProviderAdminRegistry {
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照。
pub(crate) fn resolve_account_plan(
&self,
provider_kind: &str,
plan_type: &mut Option<String>,
quota: Option<&ProviderQuota>,
) -> Option<String> {
if let Some(quota) = quota {
quota.fill_missing_plan_type(plan_type);
}
self.plan_type_display(provider_kind, plan_type.as_deref())
}
/// 账号页和 Dashboard 共用的大驼峰套餐展示名称,不修改原始套餐值。
pub(crate) fn plan_type_display(
&self,
@@ -1,12 +1,12 @@
use async_trait::async_trait;
use gateway_core::account::OutboundProxy;
use gateway_core::account::{OutboundProxy, ProviderAccountId};
use super::store::AdminStoreResult;
use crate::model::{
MutationContext, Revision,
proxies::{
ImportProxyBinding, NewProxy, ProxyListQuery, ProxyMutation, ProxyPage, ProxyRecord,
ProxyTestResult, UpdateProxy,
ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery,
ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy,
},
};
@@ -15,7 +15,18 @@ pub trait ProxyStore: Send + Sync {
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果。
async fn reserve_import(&self, id: &str) -> AdminStoreResult<ProxyImportReservation>;
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage>;
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage>;
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord>;
/// 仅在账号仍绑定指定代理时解除关联,并清除账号保存的连接地址。
async fn remove_account(
&self,
proxy_id: &str,
account_id: &ProviderAccountId,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn create(
&self,
command: NewProxy,
@@ -280,11 +280,11 @@ impl DefaultAccountsService {
)
.await?;
let usage = quota.representative_window_usage().cloned();
quota.fill_missing_plan_type(&mut stored.account.plan_type);
Ok(AccountDirectoryItem {
plan_type_display: self.providers.plan_type_display(
plan_type_display: self.providers.resolve_account_plan(
stored.account.provider_kind.as_str(),
stored.account.plan_type.as_deref(),
&mut stored.account.plan_type,
Some(&quota),
),
projection: stored.projection,
usage,
@@ -373,11 +373,11 @@ impl AccountsService for DefaultAccountsService {
.zip(quotas)
.map(|(mut item, quota)| {
let usage = quota.representative_window_usage().cloned();
quota.fill_missing_plan_type(&mut item.account.plan_type);
AccountDirectoryItem {
plan_type_display: self.providers.plan_type_display(
plan_type_display: self.providers.resolve_account_plan(
item.account.provider_kind.as_str(),
item.account.plan_type.as_deref(),
&mut item.account.plan_type,
Some(&quota),
),
usage,
account: item.account,
@@ -1,18 +1,37 @@
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::runtime::SnapshotControl;
use futures::{StreamExt, stream};
use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::SnapshotControl};
use tokio::sync::Semaphore;
use super::{map_store_error, publish_committed};
use crate::{
model::{AdminError, AdminErrorKind, MutationContext, Revision, proxies::*},
ports::proxy::{ProxyProbe, ProxyStore},
model::{
AdminError, AdminErrorKind, MutationContext, Revision,
provider_credentials::{ProviderQuotaRequest, explicit_plan_type},
proxies::*,
},
ports::{
provider::ProviderAdminRegistry,
proxy::{ProxyProbe, ProxyStore},
store::AdminStoreErrorKind,
},
};
#[async_trait]
pub trait ProxiesService: Send + Sync {
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> Result<ProxyAccountPage, AdminError>;
async fn remove_account(
&self,
proxy_id: &str,
account_id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError>;
async fn create(
&self,
command: NewProxy,
@@ -41,6 +60,7 @@ pub(crate) struct DefaultProxiesService {
store: Arc<dyn ProxyStore>,
probe: Arc<dyn ProxyProbe>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
test_slots: Semaphore,
}
@@ -49,11 +69,13 @@ impl DefaultProxiesService {
store: Arc<dyn ProxyStore>,
probe: Arc<dyn ProxyProbe>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
) -> Self {
Self {
store,
probe,
snapshot,
providers,
test_slots: Semaphore::new(4),
}
}
@@ -61,6 +83,81 @@ impl DefaultProxiesService {
#[async_trait]
impl ProxiesService for DefaultProxiesService {
async fn remove_account(
&self,
proxy_id: &str,
account_id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError> {
if proxy_id.is_empty() || proxy_id.len() > 128 || proxy_id.chars().any(char::is_control) {
return Err(AdminError::invalid("代理 ID 不合法"));
}
let account_id = ProviderAccountId::new(account_id.to_owned())
.map_err(|_| AdminError::invalid("账号 ID 不合法"))?;
let revision = self
.store
.remove_account(proxy_id, &account_id, context)
.await
.map_err(|error| {
if error.kind() == AdminStoreErrorKind::Conflict {
AdminError::conflict("账号的代理绑定已变化,请刷新后重试")
} else {
map_store_error(error, "proxy account")
}
})?;
publish_committed(self.snapshot.as_ref(), revision).await?;
Ok(revision)
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> Result<ProxyAccountPage, AdminError> {
if query.proxy_id.is_empty()
|| query.proxy_id.len() > 128
|| query.proxy_id.chars().any(char::is_control)
|| query.page == 0
|| query.search.len() > 256
|| query.search.chars().any(char::is_control)
{
return Err(AdminError::invalid("关联账号查询参数不合法"));
}
let mut page = self
.store
.list_accounts(query)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
page.items = stream::iter(page.items)
.map(|mut account| async {
// 与账号目录一致,缺失套餐时读取已有额度快照;限制并发且不触发上游刷新。
let mut cached_quota = None;
if explicit_plan_type(account.plan_type.as_deref()).is_none()
&& let Ok(kind) = ProviderKind::new(account.provider_kind.clone())
&& let Ok(provider) = self.providers.require(&kind)
&& let Ok(account_id) = ProviderAccountId::new(account.id.clone())
&& let Ok(quota) = provider
.quota(ProviderQuotaRequest {
account_id,
refresh: false,
rolling_usage: None,
})
.await
{
cached_quota = Some(quota);
}
account.plan_type_display = self.providers.resolve_account_plan(
&account.provider_kind,
&mut account.plan_type,
cached_quota.as_ref(),
);
account
})
.buffered(8)
.collect()
.await;
Ok(page)
}
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control)
{
@@ -6,11 +6,12 @@ use gateway_admin::{
store::AdminStoreResult,
},
};
use gateway_core::account::OutboundProxy;
use gateway_core::account::{OutboundProxy, ProviderAccountId};
#[derive(Default)]
pub(super) struct TestProxies {
pub events: Option<super::accounts::EventLog>,
pub accounts: Option<Vec<ProxyAccountRef>>,
}
struct ImportGuard(super::accounts::EventLog);
@@ -25,6 +26,15 @@ impl Drop for ImportGuard {
#[async_trait]
impl ProxyStore for TestProxies {
async fn remove_account(
&self,
_: &str,
_: &ProviderAccountId,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(super::unavailable("proxy"))
}
async fn reserve_import(
&self,
id: &str,
@@ -45,6 +55,21 @@ impl ProxyStore for TestProxies {
async fn list(&self, _: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
Err(super::unavailable("proxy"))
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage> {
let items = self
.accounts
.clone()
.ok_or_else(|| super::unavailable("proxy"))?;
Ok(ProxyAccountPage {
total: items.len() as u64,
items,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
Err(super::unavailable("proxy"))
}
@@ -80,6 +105,70 @@ impl ProxyProbe for TestProxies {
}
}
#[tokio::test]
async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
use super::accounts::{FakeProviderAdmin, events};
use gateway_admin::model::{PageSize, provider_credentials::ProviderQuota};
use std::sync::Arc;
for (stored, cached, expected) in [
(None, Some("free"), Some("free")),
(Some("unknown"), Some("free"), Some("free")),
(Some("plus"), Some("free"), Some("plus")),
(None, None, None),
] {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
plan_type: cached.map(str::to_owned),
observed_at: None,
refresh_token_expires_at: None,
windows: vec![],
limit_reached: false,
provider_data: None,
});
let services = super::AdminHarness::new()
.provider(provider.clone())
.proxies(Arc::new(TestProxies {
accounts: Some(vec![ProxyAccountRef {
id: "acct_plan".to_owned(),
name: "套餐测试".to_owned(),
email: None,
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
plan_type: stored.map(str::to_owned),
plan_type_display: None,
groups: vec![],
enabled: true,
}]),
..Default::default()
}))
.build()
.await;
let result = services
.proxies()
.list_accounts(ProxyAccountListQuery {
proxy_id: "proxy_plan".to_owned(),
page: 1,
page_size: PageSize::new(20).unwrap(),
search: String::new(),
})
.await
.unwrap();
assert_eq!(result.items[0].plan_type.as_deref(), expected);
assert_eq!(
result.items[0].plan_type_display.as_deref(),
match expected {
Some("free") => Some("OpenaiDisplayFree"),
Some("plus") => Some("OpenaiDisplayPlus"),
_ => None,
}
);
let requests = provider.quota_requests();
assert_eq!(requests.len(), usize::from(stored != Some("plus")));
assert!(requests.iter().all(|request| !request.refresh));
}
}
#[tokio::test]
async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_errors() {
use super::accounts::{
@@ -105,6 +194,7 @@ async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_err
.accounts(store)
.proxies(Arc::new(TestProxies {
events: Some(events.clone()),
..Default::default()
}))
.build()
.await;
+124 -11
View File
@@ -7,13 +7,16 @@ use axum::{
};
use gateway_admin::model::{
PageSize, Revision,
proxies::{NewProxy, ProxyListQuery, ProxyMutation, ProxyRecord, UpdateProxy},
proxies::{
NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord, UpdateProxy,
},
};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta, accounts::AccountProxyUpdate,
PageMeta,
accounts::{AccountGroupRefView, AccountProxyUpdate},
};
#[derive(Debug, Deserialize)]
@@ -24,6 +27,22 @@ struct ListQuery {
search: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AccountsQuery {
proxy_id: String,
page: Option<u32>,
page_size: Option<u16>,
search: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RemoveAccountRequest {
proxy_id: String,
account_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateRequest {
@@ -57,9 +76,17 @@ struct ProxyTestView {
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyAccountView {
id: String,
name: String,
email: Option<String>,
provider: String,
authentication_kind: String,
plan_type: Option<String>,
plan_type_display: Option<String>,
groups: Vec<AccountGroupRefView>,
enabled: bool,
}
#[derive(Serialize)]
@@ -70,7 +97,7 @@ struct ProxyView {
endpoint: String,
has_authentication: bool,
revision: u64,
accounts: Vec<ProxyAccountView>,
account_count: u64,
last_test_at: Option<String>,
last_test: Option<ProxyTestView>,
created_at: String,
@@ -86,14 +113,7 @@ impl From<ProxyRecord> for ProxyView {
has_authentication: record.proxy.expose_url() != endpoint,
endpoint,
revision: record.revision.get(),
accounts: record
.accounts
.into_iter()
.map(|account| ProxyAccountView {
id: account.id,
name: account.name,
})
.collect(),
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,
@@ -113,6 +133,12 @@ struct ProxyPageView {
page: PageMeta,
}
#[derive(Serialize)]
struct ProxyAccountPageView {
items: Vec<ProxyAccountView>,
page: PageMeta,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MutationView {
@@ -135,6 +161,11 @@ where
{
Router::new()
.route("/api/admin/proxies", get(list::<S>))
.route("/api/admin/proxies/accounts", get(list_accounts::<S>))
.route(
"/api/admin/proxies/accounts/remove",
post(remove_account::<S>),
)
.route("/api/admin/proxies/create", post(create::<S>))
.route("/api/admin/proxies/update", post(update::<S>))
.route("/api/admin/proxies/delete", post(delete::<S>))
@@ -186,6 +217,64 @@ where
))
}
async fn list_accounts<S>(
_: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.list_accounts(ProxyAccountListQuery {
proxy_id: query.proxy_id,
page: query.page.unwrap_or(1),
page_size: PageSize::new(query.page_size.unwrap_or(20))
.map_err(|_| AdminError::bad_request("分页大小不合法"))?,
search: query.search.unwrap_or_default().trim().to_owned(),
})
.await
.map_err(map_error)?;
let total_pages = result.total.div_ceil(u64::from(result.page_size));
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyAccountPageView {
items: result
.items
.into_iter()
.map(|account| ProxyAccountView {
id: account.id,
name: account.name,
email: account.email,
provider: account.provider_kind,
authentication_kind: account.authentication_kind,
plan_type: account.plan_type,
plan_type_display: account.plan_type_display,
groups: account
.groups
.into_iter()
.map(|group| AccountGroupRefView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.collect(),
enabled: account.enabled,
})
.collect(),
page: PageMeta::new(
result.page,
u32::from(result.page_size),
result.total,
u32::try_from(total_pages).unwrap_or(u32::MAX),
),
}),
))
}
async fn create<S>(
auth: AdminAuth,
State(state): State<S>,
@@ -216,6 +305,30 @@ where
))
}
async fn remove_account<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<RemoveAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let revision = state
.admin_services()
.proxies()
.remove_account(
&request.proxy_id,
&request.account_id,
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"configRevision": revision.get()})),
))
}
async fn update<S>(
auth: AdminAuth,
State(state): State<S>,
@@ -13,7 +13,7 @@ use gateway_admin::{
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
},
};
use gateway_core::account::OutboundProxy;
use gateway_core::account::{OutboundProxy, ProviderAccountId};
use serde_json::{Value, json};
use tower::ServiceExt as _;
@@ -28,6 +28,22 @@ fn missing() -> AdminStoreError {
#[async_trait]
impl ProxyStore for MemoryProxies {
async fn remove_account(
&self,
proxy_id: &str,
account_id: &ProviderAccountId,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
if proxy_id != "proxy_test" || account_id.as_str() != "acct_linked" {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Conflict,
"proxy account",
"changed binding",
));
}
Ok(Revision::new(4).unwrap())
}
async fn reserve_import(
&self,
_: &str,
@@ -46,6 +62,36 @@ impl ProxyStore for MemoryProxies {
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
self.0.lock().unwrap().clone().ok_or_else(missing)
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage> {
if !self
.0
.lock()
.unwrap()
.as_ref()
.is_some_and(|record| record.id == query.proxy_id)
{
return Err(missing());
}
Ok(ProxyAccountPage {
items: vec![ProxyAccountRef {
id: "acct_linked".to_owned(),
name: "工作账号".to_owned(),
email: Some("work@example.invalid".to_owned()),
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
plan_type: Some("plus".to_owned()),
plan_type_display: None,
groups: vec![],
enabled: true,
}],
total: 21,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn create(
&self,
command: NewProxy,
@@ -56,7 +102,7 @@ impl ProxyStore for MemoryProxies {
name: command.name,
proxy: command.proxy,
revision: Revision::new(1).unwrap(),
accounts: vec![],
account_count: 0,
last_test: None,
last_test_at: None,
created_at: Utc::now(),
@@ -180,6 +226,29 @@ async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_creden
"http://proxy.example:8080/"
);
assert!(created["data"]["record"].get("proxyUrl").is_none());
assert_eq!(created["data"]["record"]["accountCount"], 0);
assert!(created["data"]["record"].get("accounts").is_none());
let (status, linked) = request(
&fixture,
"/api/admin/proxies/accounts?proxyId=proxy_test&page=2&pageSize=20&search=work",
None,
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
linked["data"]["page"],
json!({"page":2,"pageSize":20,"total":21,"totalPages":2})
);
assert_eq!(
linked["data"]["items"],
json!([{
"id":"acct_linked", "name":"工作账号", "email":"work@example.invalid",
"provider":"openai", "enabled":true, "authenticationKind":"oauth",
"planType":"plus", "planTypeDisplay":"Plus", "groups":[]
}])
);
let (status, tested) = request(
&fixture,
@@ -243,7 +312,50 @@ async fn proxy_routes_require_auth_and_reject_invalid_input() {
request(&fixture, "/api/admin/proxies", None, false).await.0,
StatusCode::UNAUTHORIZED
);
for action in ["create", "update", "delete", "test"] {
assert_eq!(
request(
&fixture,
"/api/admin/proxies/accounts?proxyId=proxy_test",
None,
false
)
.await
.0,
StatusCode::UNAUTHORIZED
);
for query in [
"",
"proxyId=",
"proxyId=proxy_test&page=0",
"proxyId=proxy_test&pageSize=0",
"proxyId=proxy_test&pageSize=201",
"proxyId=proxy_test&search=%00",
"proxyId=proxy_test&unknown=1",
] {
assert_eq!(
request(
&fixture,
&format!("/api/admin/proxies/accounts?{query}"),
None,
true
)
.await
.0,
StatusCode::BAD_REQUEST
);
}
assert_eq!(
request(
&fixture,
"/api/admin/proxies/accounts?proxyId=missing",
None,
true
)
.await
.0,
StatusCode::NOT_FOUND
);
for action in ["create", "update", "delete", "test", "accounts/remove"] {
assert_eq!(
request(
&fixture,
@@ -284,3 +396,57 @@ async fn proxy_routes_require_auth_and_reject_invalid_input() {
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn proxy_account_removal_validates_binding_and_input() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (body, expected) in [
(
json!({"proxyId":"", "accountId":"acct_linked"}),
StatusCode::BAD_REQUEST,
),
(
json!({"proxyId":"proxy_test", "accountId":""}),
StatusCode::BAD_REQUEST,
),
(
json!({"proxyId":"proxy_test", "accountId":"invalid"}),
StatusCode::BAD_REQUEST,
),
(
json!({"proxyId":"proxy_test", "accountId":"acct_linked", "enabled":false}),
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
assert_eq!(
request(
&fixture,
"/api/admin/proxies/accounts/remove",
Some(body),
true
)
.await
.0,
expected
);
}
let (status, result) = request(
&fixture,
"/api/admin/proxies/accounts/remove",
Some(json!({"proxyId":"proxy_test", "accountId":"acct_linked"})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["data"], json!({"configRevision":4}));
let (status, result) = request(
&fixture,
"/api/admin/proxies/accounts/remove",
Some(json!({"proxyId":"proxy_previous", "accountId":"acct_linked"})),
true,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(result["message"], "账号的代理绑定已变化,请刷新后重试");
}
@@ -1,16 +1,23 @@
//! 命名代理持有认证信息,账号保留解析后的 URL 供 Provider 传输使用。
use std::sync::Arc;
use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
use gateway_admin::{
model::{MutationContext, Revision as AdminRevision, proxies::*},
model::{
MutationContext, Revision as AdminRevision,
account_groups::{AccountGroupColor, AccountGroupRef},
proxies::*,
},
ports::{
proxy::{ProxyImportGuard, ProxyImportReservation, ProxyStore},
store::{AdminStoreError, AdminStoreResult},
},
};
use gateway_core::account::OutboundProxy;
use gateway_core::{
account::{OutboundProxy, ProviderAccountId},
routing::AccountGroupId,
};
use sqlx::{PgConnection, PgPool, Postgres, QueryBuilder, Row as _, Transaction, postgres::PgRow};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
@@ -22,8 +29,7 @@ use crate::{
const ENTITY: &str = "outbound proxy";
const SELECT: &str = "select p.*,
array(select a.id from provider_accounts a where a.outbound_proxy_id = p.id order by a.id) as account_ids,
array(select a.name from provider_accounts a where a.outbound_proxy_id = p.id order by a.id) as account_names
(select count(*) from provider_accounts a where a.outbound_proxy_id = p.id) as account_count
from outbound_proxies p";
#[derive(Clone)]
@@ -91,8 +97,6 @@ fn record(row: PgRow) -> StoreResult<ProxyRecord> {
let success: Option<bool> = row.try_get("last_test_success").map_err(|_| invalid())?;
let ip: Option<String> = row.try_get("last_test_ip").map_err(|_| invalid())?;
let latency: Option<i64> = row.try_get("last_test_latency_ms").map_err(|_| invalid())?;
let ids: Vec<String> = row.try_get("account_ids").map_err(|_| invalid())?;
let names: Vec<String> = row.try_get("account_names").map_err(|_| invalid())?;
Ok(ProxyRecord {
id: row.try_get("id").map_err(|_| invalid())?,
name: row.try_get("name").map_err(|_| invalid())?,
@@ -106,11 +110,11 @@ fn record(row: PgRow) -> StoreResult<ProxyRecord> {
.map_err(|_| invalid())?,
)
.map_err(|_| invalid())?,
accounts: ids
.into_iter()
.zip(names)
.map(|(id, name)| ProxyAccountRef { id, name })
.collect(),
account_count: u64::try_from(
row.try_get::<i64, _>("account_count")
.map_err(|_| invalid())?,
)
.map_err(|_| invalid())?,
last_test_at: row.try_get("last_test_at").map_err(|_| invalid())?,
last_test: success
.map(|success| -> StoreResult<_> {
@@ -223,6 +227,158 @@ async fn audit(
#[async_trait]
impl ProxyStore for PgProxyRepository {
async fn remove_account(
&self,
proxy_id: &str,
account_id: &ProviderAccountId,
context: &MutationContext,
) -> AdminStoreResult<AdminRevision> {
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| store_error(unavailable()))?;
let revision = bump_config_revision_in_transaction(&mut transaction)
.await
.map_err(store_error)?;
// 在同一条更新中校验绑定,避免旧弹窗清除账号后来选择的其他代理。
let updated = sqlx::query(
"update provider_accounts
set outbound_proxy_id = null, outbound_proxy_url = null,
updated_at = greatest(now(), updated_at)
where id = $1 and outbound_proxy_id = $2",
)
.bind(account_id.as_str())
.bind(proxy_id)
.execute(&mut *transaction)
.await
.map_err(|_| store_error(unavailable()))?;
if updated.rows_affected() != 1 {
return Err(store_error(conflict(proxy_id)));
}
append_admin_audit_event_in_transaction(
&mut transaction,
mutation_audit(
context,
"update",
"provider_account",
account_id.as_str(),
vec!["outbound_proxy".to_owned()],
),
revision,
)
.await
.map_err(store_error)?;
transaction
.commit()
.await
.map_err(|_| store_error(unavailable()))?;
admin_revision(revision)
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage> {
if query.page == 0 {
return Err(store_error(invalid()));
}
let mut transaction = self
.pool
.begin()
.await
.map_err(|_| store_error(unavailable()))?;
// 数量和当前页共用只读快照,避免绑定变化使同一次响应的分页事实不一致。
sqlx::query("set transaction isolation level repeatable read, read only")
.execute(&mut *transaction)
.await
.map_err(|_| store_error(unavailable()))?;
let total: i64 = sqlx::query_scalar(
"select count(a.id) from outbound_proxies p
left join provider_accounts a on a.outbound_proxy_id = p.id
and (strpos(lower(a.name), lower($2)) > 0
or strpos(lower(coalesce(a.email, '')), lower($2)) > 0)
where p.id = $1 group by p.id",
)
.bind(&query.proxy_id)
.bind(&query.search)
.fetch_optional(&mut *transaction)
.await
.map_err(|_| store_error(unavailable()))?
.ok_or_else(|| {
store_error(StoreError::NotFound {
entity: ENTITY,
id: query.proxy_id.clone(),
})
})?;
let mut items = sqlx::query(
"select id, name, email, provider_kind, authentication_kind, plan_type, enabled from provider_accounts
where outbound_proxy_id = $1 and (strpos(lower(name), lower($2)) > 0
or strpos(lower(coalesce(email, '')), lower($2)) > 0)
order by name, id limit $3 offset $4",
)
.bind(&query.proxy_id)
.bind(&query.search)
.bind(i64::from(query.page_size.get()))
.bind(i64::from(query.page - 1) * i64::from(query.page_size.get()))
.fetch_all(&mut *transaction)
.await
.map_err(|_| store_error(unavailable()))?
.into_iter()
.map(|row| {
Ok(ProxyAccountRef {
id: row.try_get("id").map_err(|_| invalid())?,
name: row.try_get("name").map_err(|_| invalid())?,
email: row.try_get("email").map_err(|_| invalid())?,
provider_kind: row.try_get("provider_kind").map_err(|_| invalid())?,
authentication_kind: row.try_get("authentication_kind").map_err(|_| invalid())?,
plan_type: row.try_get("plan_type").map_err(|_| invalid())?,
plan_type_display: None,
groups: Vec::new(),
enabled: row.try_get("enabled").map_err(|_| invalid())?,
})
})
.collect::<StoreResult<Vec<_>>>()
.map_err(store_error)?;
if !items.is_empty() {
// 仅批量加载当前页的分组,并沿用同一快照,避免逐账号查询。
let account_ids: Vec<&str> = items.iter().map(|account| account.id.as_str()).collect();
let rows = sqlx::query_as::<_, (String, String, String, String, bool)>(
"select m.provider_account_id, g.id, g.name, g.color, g.enabled
from account_group_accounts m join account_groups g on g.id = m.account_group_id
where m.provider_account_id = any($1::text[])
order by m.provider_account_id, g.id",
)
.bind(&account_ids)
.fetch_all(&mut *transaction)
.await
.map_err(|_| store_error(unavailable()))?;
let mut groups = BTreeMap::<String, Vec<AccountGroupRef>>::new();
for (account_id, group_id, name, color, enabled) in rows {
groups.entry(account_id).or_default().push(AccountGroupRef {
id: AccountGroupId::new(group_id).map_err(|_| store_error(invalid()))?,
name,
color: AccountGroupColor::parse(&color)
.ok_or_else(|| store_error(invalid()))?,
enabled,
});
}
for account in &mut items {
account.groups = groups.remove(&account.id).unwrap_or_default();
}
}
transaction
.commit()
.await
.map_err(|_| store_error(unavailable()))?;
Ok(ProxyAccountPage {
items,
total: u64::try_from(total).map_err(|_| store_error(invalid()))?,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn reserve_import(&self, id: &str) -> AdminStoreResult<ProxyImportReservation> {
let slot = self
.import_slots
@@ -1778,7 +1778,17 @@ async fn authorization_import_rejects_a_saved_proxy_changed_during_oauth() {
.await
.unwrap();
assert_eq!(
proxies.get(&saved.id).await.unwrap().accounts[0].id,
proxies
.list_accounts(gateway_admin::model::proxies::ProxyAccountListQuery {
proxy_id: saved.id.clone(),
page: 1,
page_size: gateway_admin::model::PageSize::new(20).unwrap(),
search: String::new(),
})
.await
.unwrap()
.items[0]
.id,
"acct_oauth_proxy"
);
assert_eq!(
@@ -9,7 +9,7 @@ use gateway_admin::{
store::{AccountStore, AdminStoreErrorKind},
},
};
use gateway_core::account::{AccountWeight, OutboundProxy};
use gateway_core::account::{AccountWeight, OutboundProxy, ProviderAccountId};
use gateway_store::postgres::{
PgProviderAccountRepository, PgProxyRepository, ProviderAccountRepository,
};
@@ -43,6 +43,298 @@ fn update(account_id: &str, selection: AccountProxySelection) -> UpdateAccount {
}
}
#[tokio::test]
async fn proxy_account_removal_preserves_settings_and_rejects_changed_bindings() {
let Some(database) = TestDatabase::create("proxy_account_remove").await else {
return;
};
let store = PgProxyRepository::new(database.pool.clone());
let context = context();
let saved = store
.create(
NewProxy {
name: "解绑测试".to_owned(),
proxy: OutboundProxy::parse("http://user:secret@127.0.0.1:17890").unwrap(),
},
&context,
)
.await
.unwrap()
.record;
sqlx::query(
"insert into provider_accounts
(id, provider_kind, name, email, authentication_kind, provider_credentials_json,
has_refresh_token, enabled, concurrency_limit, weight, plan_type,
credential_observed_at, created_at, updated_at, outbound_proxy_id, outbound_proxy_url)
values ('acct_remove', 'openai', '解绑账号', 'remove@example.invalid', 'oauth',
'{\"access_token\":\"preserve-test-secret\"}'::jsonb, false, false, 3, 7, 'plus',
now(), now(), now(), $1, $2)",
)
.bind(&saved.id)
.bind(saved.proxy.expose_url())
.execute(&database.pool)
.await
.unwrap();
sqlx::query(
"insert into account_groups (id, name, color, enabled, created_at, updated_at)
values ('grp_00000000000000000000000000000001', '保留分组', '#123456FF', true, now(), now())",
).execute(&database.pool).await.unwrap();
sqlx::query(
"insert into account_group_accounts (account_group_id, provider_account_id, created_at)
values ('grp_00000000000000000000000000000001', 'acct_remove', now())",
)
.execute(&database.pool)
.await
.unwrap();
let account_id = ProviderAccountId::new("acct_remove").unwrap();
let snapshot = "select to_jsonb(a) - 'updated_at' - 'outbound_proxy_id' - 'outbound_proxy_url'
from provider_accounts a where id = 'acct_remove'";
let before: serde_json::Value = sqlx::query_scalar(snapshot)
.fetch_one(&database.pool)
.await
.unwrap();
let revision: i64 =
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
.fetch_one(&database.pool)
.await
.unwrap();
let audit_count: i64 = sqlx::query_scalar("select count(*) from admin_audit_events")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(
store
.remove_account("proxy_previous", &account_id, &context)
.await
.unwrap_err()
.kind(),
AdminStoreErrorKind::Conflict
);
assert_eq!(store.get(&saved.id).await.unwrap().account_count, 1);
let unchanged: (Option<String>, Option<String>) = sqlx::query_as(
"select outbound_proxy_id, outbound_proxy_url from provider_accounts where id = 'acct_remove'",
).fetch_one(&database.pool).await.unwrap();
assert_eq!(
unchanged,
(
Some(saved.id.clone()),
Some(saved.proxy.expose_url().to_owned())
)
);
let current_revision: i64 =
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(current_revision, revision);
let committed = store
.remove_account(&saved.id, &account_id, &context)
.await
.unwrap();
assert_eq!(committed.get(), u64::try_from(revision + 1).unwrap());
let after: serde_json::Value = sqlx::query_scalar(snapshot)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(after, before);
let direct: (Option<String>, Option<String>) = sqlx::query_as(
"select outbound_proxy_id, outbound_proxy_url from provider_accounts where id = 'acct_remove'",
).fetch_one(&database.pool).await.unwrap();
assert_eq!(direct, (None, None));
let group_count: i64 = sqlx::query_scalar(
"select count(*) from account_group_accounts where provider_account_id = 'acct_remove'",
)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(group_count, 1);
assert_eq!(store.get(&saved.id).await.unwrap().account_count, 0);
assert_eq!(
store
.list_accounts(ProxyAccountListQuery {
proxy_id: saved.id.clone(),
page: 1,
page_size: PageSize::new(20).unwrap(),
search: String::new(),
})
.await
.unwrap()
.total,
0
);
assert_eq!(
store
.remove_account(&saved.id, &account_id, &context)
.await
.unwrap_err()
.kind(),
AdminStoreErrorKind::Conflict
);
let current_audits: i64 = sqlx::query_scalar("select count(*) from admin_audit_events")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(current_audits, audit_count + 1);
let current_revision: i64 =
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(u64::try_from(current_revision).unwrap(), committed.get());
database.close().await;
}
#[tokio::test]
async fn proxy_accounts_paginate_thousands_of_accounts_and_search_without_loading_the_catalog() {
let Some(database) = TestDatabase::create("proxy_account_pagination").await else {
return;
};
let store = PgProxyRepository::new(database.pool.clone());
let context = context();
let saved = store
.create(
NewProxy {
name: "分页测试".to_owned(),
proxy: OutboundProxy::parse("http://127.0.0.1:17890").unwrap(),
},
&context,
)
.await
.unwrap()
.record;
let empty = store
.create(
NewProxy {
name: "无关联账号".to_owned(),
proxy: OutboundProxy::parse("http://127.0.0.1:17891").unwrap(),
},
&context,
)
.await
.unwrap()
.record;
// 相同名称验证稳定排序;账号仅写入隔离测试数据库,不接触真实账号池。
sqlx::query(
"insert into provider_accounts
(id, provider_kind, name, email, authentication_kind, provider_credentials_json,
has_refresh_token, enabled, credential_observed_at, created_at, updated_at,
outbound_proxy_id, outbound_proxy_url)
select 'acct_page_' || lpad(n::text, 4, '0'),
case when n % 2 = 0 then 'xai' else 'openai' end, '共享账号',
'page_' || lpad(n::text, 4, '0') || '@example.invalid', 'oauth',
'{\"access_token\":\"page-test-secret\"}'::jsonb, false, n % 2 = 0,
now(), now(), now(), $1, $2
from generate_series(1, 1005) as n",
)
.bind(&saved.id)
.bind(saved.proxy.expose_url())
.execute(&database.pool)
.await
.unwrap();
sqlx::query("update provider_accounts set plan_type = 'plus' where id = 'acct_page_0001'")
.execute(&database.pool)
.await
.unwrap();
sqlx::query(
"insert into account_groups (id, name, color, enabled, created_at, updated_at)
values ('grp_00000000000000000000000000000001', '工作组', '#123456FF', false, now(), now()),
('grp_00000000000000000000000000000002', '次页分组', '#654321FF', true, now(), now())",
).execute(&database.pool).await.unwrap();
sqlx::query(
"insert into account_group_accounts (account_group_id, provider_account_id, created_at)
values ('grp_00000000000000000000000000000001', 'acct_page_0001', now()),
('grp_00000000000000000000000000000002', 'acct_page_0021', now())",
)
.execute(&database.pool)
.await
.unwrap();
PgProviderAccountRepository::new(database.pool.clone())
.insert_provider_account(account("acct_unrelated", "unrelated-user"))
.await
.unwrap();
let query = |page, search: &str| ProxyAccountListQuery {
proxy_id: saved.id.clone(),
page,
page_size: PageSize::new(20).unwrap(),
search: search.to_owned(),
};
let first = store.list_accounts(query(1, "")).await.unwrap();
assert_eq!((first.total, first.items.len()), (1005, 20));
assert_eq!(first.items[0].id, "acct_page_0001");
assert_eq!(
first.items[0].email.as_deref(),
Some("page_0001@example.invalid")
);
assert_eq!(first.items[0].provider_kind, "openai");
assert_eq!(first.items[0].authentication_kind, "oauth");
assert_eq!(first.items[0].plan_type.as_deref(), Some("plus"));
assert_eq!(first.items[0].groups.len(), 1);
assert_eq!(first.items[0].groups[0].name, "工作组");
assert_eq!(first.items[0].groups[0].color.as_str(), "#123456FF");
assert!(!first.items[0].groups[0].enabled);
assert!(first.items[1].groups.is_empty());
assert!(!first.items[0].enabled);
let second = store.list_accounts(query(2, "")).await.unwrap();
assert_eq!(second.items[0].id, "acct_page_0021");
assert_eq!(second.items[0].groups[0].name, "次页分组");
assert!(
first
.items
.iter()
.all(|left| second.items.iter().all(|right| left.id != right.id))
);
let last = store.list_accounts(query(51, "")).await.unwrap();
assert_eq!((last.total, last.items.len()), (1005, 5));
assert_eq!(last.items[4].id, "acct_page_1005");
let filtered = store.list_accounts(query(1, "PAGE_004")).await.unwrap();
assert_eq!((filtered.total, filtered.items.len()), (10, 10));
assert_eq!(filtered.items[0].id, "acct_page_0040");
assert_eq!(store.list_accounts(query(1, "%")).await.unwrap().total, 0);
assert!(
store
.list_accounts(query(52, ""))
.await
.unwrap()
.items
.is_empty()
);
assert_eq!(
store
.list_accounts(ProxyAccountListQuery {
proxy_id: empty.id,
..query(1, "")
})
.await
.unwrap()
.total,
0
);
assert_eq!(
store
.list_accounts(ProxyAccountListQuery {
proxy_id: "missing".to_owned(),
..query(1, "")
})
.await
.unwrap_err()
.kind(),
AdminStoreErrorKind::NotFound
);
let catalog = store
.list(ProxyListQuery {
page: 1,
page_size: PageSize::new(20).unwrap(),
search: "分页测试".to_owned(),
})
.await
.unwrap();
assert_eq!(catalog.items[0].account_count, 1005);
database.close().await;
}
#[tokio::test]
async fn import_reservation_blocks_proxy_mutations_until_rotated_credentials_are_committed() {
use gateway_store::postgres::{
@@ -218,7 +510,7 @@ async fn managed_proxies_persist_bind_update_all_accounts_and_protect_stale_test
.await
.unwrap();
}
assert_eq!(store.get(&created.id).await.unwrap().accounts.len(), 2);
assert_eq!(store.get(&created.id).await.unwrap().account_count, 2);
assert_eq!(
store
.delete(&created.id, created.revision, &context)
@@ -334,7 +626,7 @@ async fn legacy_urls_join_one_catalog_entry_and_invalid_batch_rolls_back() {
.await
.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.items[0].accounts.len(), 2);
assert_eq!(page.items[0].account_count, 2);
let admin = admin_account_store(&database.pool);
assert!(
admin
+11 -1
View File
@@ -219,16 +219,26 @@ All endpoints require admin authentication and redact proxy credentials from res
| 方法 / Method | 路径 / Path | 请求 / Request | 结果 / Result |
| --- | --- | --- | --- |
| `GET` | `/api/admin/proxies` | `page`、`pageSize`(1-200)、`search`(名称) | `{ items, page }` |
| `GET` | `/api/admin/proxies/accounts` | `proxyId`、`page`、`pageSize`(1-200)、`search`(账号名称或邮箱) | `{ items, page }` |
| `POST` | `/api/admin/proxies/accounts/remove` | `{ proxyId, accountId }` | `{ configRevision }` |
| `POST` | `/api/admin/proxies/create` | `{ name, proxyUrl }` | `201 { record, configRevision }` |
| `POST` | `/api/admin/proxies/update` | `{ id, revision, name, proxyUrl? }` | `{ record, configRevision }` |
| `POST` | `/api/admin/proxies/test` | `{ id, revision }` | 最新代理记录 / Proxy record with test result |
| `POST` | `/api/admin/proxies/delete` | `{ id, revision }` | `{ configRevision }` |
`record` 包含 `id`、`name`、`endpoint`、`hasAuthentication`、`revision`、`accounts: [{ id, name }]`、
`record` 包含 `id`、`name`、`endpoint`、`hasAuthentication`、`revision`、`accountCount`、
`lastTestAt`、`lastTest: { success, latencyMs, exitIp, message }`、`createdAt`、`updatedAt`。
未测试时 `lastTestAt` / `lastTest` 为 `null`。连通性失败返回 HTTP 200 和 `lastTest.success=false`;
记录版本过期、重复 URL、删除已绑定的代理返回 409,并发测试满载返回 429。
代理列表只返回关联账号数量。关联账号按需查询,每项包含 `id`、`name`、`email`、`provider`、`enabled`、
`authenticationKind`、`planType`、`planTypeDisplay` 和 `groups: [{ id, name, color, enabled }]`,
不返回账号凭据。默认每页 20 条,按名称、ID 稳定排序;搜索不区分大小写,匹配名称或邮箱的字面子串。
不存在的代理返回 404,未绑定账号或没有匹配结果时返回空页。数量与当前页来自同一个数据库只读快照。
移除关联账号只清除指定账号的代理绑定与连接地址,使其改为直连,保留凭据、调度参数与分组。
若账号已不再绑定请求中的代理,则返回 409;成功后在同一事务中更新配置版本与审计,并发布运行时快照。
更新省略 `proxyUrl` 保留认证;连接配置改变时清除测试结果并更新所有绑定账号。
Omit `proxyUrl` to preserve credentials. Connection changes invalidate the previous test and update all bound accounts.
Tests persist only when the requested revision still matches. Connectivity failures use HTTP 200 with
+4
View File
@@ -191,6 +191,10 @@ Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 ca
代理独立保存和测试,通过 `outboundProxyId` 绑定账号;账号保留解析后的 URL 供 Provider 使用。
连接配置改变时在同一事务内同步关联账号并清除旧测试结果,已绑定的代理不可删除。
关联账号支持单独移除:在原子更新中校验当前代理 ID,只清除账号的代理 ID 和 URL,
保留账号其他设置;提交配置版本与审计后复用快照发布流程,使后续请求使用直连。
代理目录只携带关联数量;账号明细通过独立分页读端口按需查询,复用代理 ID 索引,
在同一个只读快照中统计并读取有界页面,不在目录中聚合完整账号数组。
文件及 AT/RT 导入在上游凭据交换前取得 PostgreSQL 会话级共享咨询锁,直到凭据提交后释放。
代理修改、删除和测试结果写入通过同一资源的事务级独占锁检查,导入期间直接返回冲突。
锁连接不占用提交所需的连接池槽位,也不持有空闲事务;每进程最多增加四个导入保护连接。
+61 -6
View File
@@ -1,3 +1,4 @@
import type { AccountGroupRef } from './account-groups'
import request from '../request'
export interface OutboundProxyTest {
@@ -13,7 +14,7 @@ export interface OutboundProxyRecord {
endpoint: string
hasAuthentication: boolean
revision: number
accounts: { id: string, name: string }[]
accountCount: number
lastTestAt: string | null
lastTest: OutboundProxyTest | null
createdAt: string
@@ -25,27 +26,81 @@ interface ProxyPage {
page: { page: number, pageSize: number, total: number, totalPages: number }
}
export interface OutboundProxyAccount {
id: string
name: string
email: string | null
provider: string
authenticationKind: string
planType: string | null
planTypeDisplay: string | null
groups: AccountGroupRef[]
enabled: boolean
}
interface ProxyAccountPage {
items: OutboundProxyAccount[]
page: ProxyPage['page']
}
export function getProxyAccounts(params: { proxyId: string, page: number, pageSize: number, search?: string }) {
return request<ProxyAccountPage>({
url: '/api/admin/proxies/accounts',
method: 'GET',
params,
})
}
export function removeProxyAccount(data: { proxyId: string, accountId: string }) {
return request<{ configRevision: number }>({
url: '/api/admin/proxies/accounts/remove',
method: 'POST',
data,
})
}
interface ProxyMutation {
record: OutboundProxyRecord
configRevision: number
}
export function getProxies(params: { page: number, pageSize: number, search?: string }) {
return request<ProxyPage>({ url: '/api/admin/proxies', method: 'GET', params })
return request<ProxyPage>({
url: '/api/admin/proxies',
method: 'GET',
params,
})
}
export function createProxy(data: { name: string, proxyUrl: string }) {
return request<ProxyMutation>({ url: '/api/admin/proxies/create', method: 'POST', data })
return request<ProxyMutation>({
url: '/api/admin/proxies/create',
method: 'POST',
data,
})
}
export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }) {
return request<ProxyMutation>({ url: '/api/admin/proxies/update', method: 'POST', data })
return request<ProxyMutation>({
url: '/api/admin/proxies/update',
method: 'POST',
data,
})
}
export function deleteProxy(data: { id: string, revision: number }) {
return request<{ configRevision: number }>({ url: '/api/admin/proxies/delete', method: 'POST', data })
return request<{ configRevision: number }>({
url: '/api/admin/proxies/delete',
method: 'POST',
data,
})
}
export function testProxy(data: { id: string, revision: number }) {
return request<OutboundProxyRecord>({ url: '/api/admin/proxies/test', method: 'POST', data, timeout: 25000 })
return request<OutboundProxyRecord>({
url: '/api/admin/proxies/test',
method: 'POST',
data,
timeout: 25000,
})
}
+19 -2
View File
@@ -12,6 +12,7 @@ type SelectSize = 'sm' | 'md' | 'lg'
export interface SelectOption {
label: string
value: string
description?: string
disabled?: boolean
}
@@ -303,9 +304,17 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
@click="toggleMenu"
@keydown="handleTriggerKeydown"
>
<span class="min-w-0 flex-1 truncate">
<span class="min-w-0 truncate" :class="selectedOption?.description ? 'max-w-1/2 shrink-0' : 'flex-1'">
{{ selectedOption?.label ?? placeholder }}
</span>
<span
v-if="selectedOption?.description"
:title="selectedOption.description"
class="min-w-0 flex-1 truncate text-xs font-normal"
:class="disabled ? 'text-cp-text-disabled' : 'text-cp-text-tertiary'"
>
{{ selectedOption.description }}
</span>
<ChevronDown
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 transition-transform"
:class="
@@ -355,7 +364,15 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
@mousedown.prevent
@click="chooseOption(option, index)"
>
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
<span class="min-w-0 truncate" :class="option.description ? 'max-w-1/2 shrink-0' : 'flex-1'">{{ option.label }}</span>
<span
v-if="option.description"
:title="option.description"
class="min-w-0 flex-1 truncate text-xs font-normal"
:class="option.disabled ? 'text-cp-text-disabled' : 'text-cp-text-tertiary'"
>
{{ option.description }}
</span>
<Check
v-if="option.value === model"
class="shrink-0 text-cp-primary-text"
@@ -1,22 +1,27 @@
<script setup lang="ts">
import { ExternalLink, RefreshCw } from '@lucide/vue'
import { computed } from 'vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import { useProxyCatalog } from '@/composables/useProxyCatalog'
const props = withDefaults(defineProps<{ accountId?: string, endpoint?: string | null, disabled?: boolean, preserve?: boolean, error?: string }>(), { preserve: true })
const mode = defineModel<string>('mode', { required: true })
const proxyId = defineModel<string>('proxyId', { required: true })
const { proxies, loading, loadProxies } = useProxyCatalog()
const current = computed(() => proxies.value.find(proxy => proxy.accounts.some(account => account.id === props.accountId)))
const { proxies, loading } = useProxyCatalog()
// 目录不再携带账号明细;脱敏地址无法证明绑定关系,保持项只展示账号已有地址。
const options = computed(() => [
...(props.preserve ? [{ label: current.value ? `保持当前:${current.value.name}` : '保持当前', value: 'preserve' }] : []),
{ label: '直连', value: 'direct' },
...(props.preserve
? [{
label: '保持当前',
value: 'preserve',
description: props.endpoint || (props.accountId ? '直连' : '保留各账号的连接设置'),
}]
: []),
{ label: '直连', value: 'direct', description: '不使用代理' },
...proxies.value.map(proxy => ({
label: `${proxy.name}${proxy.lastTest?.success ? '' : proxy.lastTest ? '(测试失败)' : '(未测试)'}`,
value: proxy.id,
description: proxy.endpoint,
disabled: proxy.lastTest?.success !== true,
})),
])
@@ -31,21 +36,7 @@ const selection = computed({
</script>
<template>
<div class="grid min-w-0 grid-cols-1 gap-3">
<BaseFormItem label="出站隧道" :error="error">
<div class="flex min-w-0 items-center gap-2">
<BaseSelect v-model="selection" class="min-w-0 flex-1" :options="options" :disabled="disabled || loading" aria-label="出站隧道" />
<BaseIconButton label="刷新可选代理" :disabled="disabled || loading" @click="loadProxies()">
<RefreshCw class="size-4" />
</BaseIconButton>
<a href="/proxies" target="_blank" rel="noopener noreferrer" class="inline-flex size-8 shrink-0 items-center justify-center text-cp-link" aria-label="打开代理管理" title="打开代理管理"><ExternalLink class="size-4" /></a>
</div>
</BaseFormItem>
<p v-if="endpoint && mode === 'preserve'" class="m-0 font-mono text-xs break-all text-cp-text-tertiary">
{{ endpoint }}
</p>
<p v-if="mode === 'proxy'" class="m-0 break-all font-mono text-xs text-cp-text-secondary">
{{ proxies.find(proxy => proxy.id === proxyId)?.endpoint }}
</p>
</div>
<BaseFormItem label="出站隧道" :error="error">
<BaseSelect v-model="selection" class="w-full" :options="options" :disabled="disabled || loading" aria-label="出站隧道" />
</BaseFormItem>
</template>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import type { OutboundProxyAccount, OutboundProxyRecord } from '@/api'
import { Search, Unlink } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import AccountGroupMarks from '@/components/AccountGroupMarks.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination.vue'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
import { stablePresetVisualToneClass } from '@/views/accounts/utils/visualTone'
import { useProxyAccounts } from '../composables/useProxyAccounts'
const props = defineProps<{
proxy: OutboundProxyRecord | null
}>()
const emit = defineEmits<{ removed: [] }>()
const open = defineModel<boolean>({ required: true })
const pendingRemove = shallowRef<OutboundProxyAccount | null>(null)
const showRemove = computed({
get: () => pendingRemove.value !== null,
set: (value: boolean) => {
if (!value)
pendingRemove.value = null
},
})
const { accounts, loading, error, pagination, search, setPage, setPageSize, removing, removeAccount } = useProxyAccounts({
isOpen: () => open.value,
proxyId: () => props.proxy?.id,
onRemoved: () => emit('removed'),
})
const columns = defineTableColumns<OutboundProxyAccount>([
{ key: 'identity', label: '账号', kind: 'identity', size: '3xl' },
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'md', align: 'center' },
{ key: 'plan', label: '套餐', kind: 'custom', size: 'sm', align: 'center' },
{ key: 'groups', label: '分组', kind: 'custom', size: 'lg', align: 'center' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'xs', align: 'center' },
])
const rows = computed(() => accounts.value.map(account => ({
...account,
displayName: account.email && account.name === account.email
? account.email.split('@')[0]
: account.name,
initial: Array.from(account.name.trim())[0]?.toUpperCase() || '—',
avatarTone: stablePresetVisualToneClass(account.id),
})))
const description = computed(() => props.proxy
? props.proxy.name
: undefined)
const emptyText = computed(() => error.value
|| (search.value.trim() ? '没有找到匹配的账号,请尝试其他名称或邮箱' : '暂无关联账号'))
const tableHeight = computed(() => {
// 空列表与少量账号保留最小高度,多行限制高度并在表格内滚动。
const count = accounts.value.length || (loading.value
? Math.min(pagination.value.total || props.proxy?.accountCount || 0, pagination.value.pageSize)
: 0)
return `min(55dvh, max(20rem, min(30rem, ${44 + count * 64}px)))`
})
async function confirmRemove() {
const account = pendingRemove.value
if (account && await removeAccount(account.id))
pendingRemove.value = null
}
watch([open, () => props.proxy?.id], () => {
pendingRemove.value = null
})
</script>
<template>
<BaseModal v-model="open" title="关联账号" :description="description" size="lg" :dismissible="!removing">
<div class="flex min-h-0 flex-col">
<BaseInput v-model="search" class="mb-4 shrink-0 sm:w-80" :disabled="removing" aria-label="搜索关联账号" placeholder="搜索账号名称或邮箱...">
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</BaseInput>
<BaseTable :key="proxy?.id" class="min-h-0 shrink-0 [--cp-table-row-height:64px]" :style="{ height: tableHeight }" :columns="columns" :rows="rows" :loading="loading" :empty-text="emptyText">
<template #identity="{ row }">
<div class="flex min-w-0 items-center gap-3">
<span class="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-cp font-extrabold" :class="row.avatarTone" aria-hidden="true">
{{ row.initial }}
</span>
<div class="min-w-0 flex-1">
<div class="truncate text-cp font-heavy text-cp-text" :title="row.name">
{{ row.displayName }}
</div>
<div v-if="row.email" class="mt-0.5 truncate font-mono text-cp-xs font-emphasis text-cp-text-quaternary" :title="row.email">
{{ row.email }}
</div>
</div>
</div>
</template>
<template #provider="{ row }">
<ProviderIconGroup :provider="row.provider" :authentication-kind="row.authenticationKind" />
</template>
<template #plan="{ row }">
<AccountPlanBadge v-if="row.planType && row.planTypeDisplay" :plan-type="row.planType" :plan-type-display="row.planTypeDisplay" />
<span v-else class="text-cp-xs text-cp-text-quaternary">—</span>
</template>
<template #groups="{ row }">
<div class="flex justify-center">
<AccountGroupMarks :groups="row.groups || []" />
</div>
</template>
<template #actions="{ row }">
<BaseIconButton
size="sm"
label="从当前代理移除账号"
:disabled="removing"
:loading="removing && pendingRemove?.id === row.id"
@click="pendingRemove = row"
>
<Unlink class="size-3.5 text-cp-error" />
</BaseIconButton>
</template>
</BaseTable>
<BaseTablePagination :pagination="pagination" :loading="loading || removing" @page-change="setPage" @page-size-change="setPageSize" />
</div>
</BaseModal>
<BaseConfirmModal v-model="showRemove" title="移除关联账号" confirm-text="移除" :loading="removing" @confirm="confirmRemove">
<p class="m-0 break-words">
将“{{ pendingRemove?.name }}”从“{{ proxy?.name }}”移除后,该账号将改为直连。
</p>
</BaseConfirmModal>
</template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { OutboundProxyRecord } from '@/api'
import { Eye, EyeOff, Save, Wifi } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
const props = defineProps<{
proxy: OutboundProxyRecord | null
saving: boolean
}>()
const emit = defineEmits<{
save: [testAfter: boolean]
}>()
const open = defineModel<boolean>({ required: true })
const name = defineModel<string>('name', { required: true })
const proxyUrl = defineModel<string>('proxyUrl', { required: true })
const showSecret = shallowRef(false)
const title = computed(() => props.proxy ? '编辑代理' : '新增代理')
const connectionDescription = computed(() => props.proxy
? '留空保留当前连接和认证信息;填写新地址时,请包含所需的用户名和密码。'
: '支持 HTTP、HTTPS、SOCKS5 和 SOCKS5H,可在地址中包含用户名和密码。')
watch(open, () => {
showSecret.value = false
})
</script>
<template>
<BaseModal v-model="open" :title="title" size="md" :dismissible="!saving">
<BaseForm class="grid gap-5">
<BaseFormItem label="代理名称" required>
<BaseInput v-model="name" maxlength="100" :disabled="saving" aria-label="代理名称" placeholder="例如:本机代理" />
</BaseFormItem>
<BaseFormItem label="代理 URL" :required="!proxy" :description="connectionDescription">
<BaseInput
v-model="proxyUrl"
:type="showSecret ? 'text' : 'password'"
autocomplete="new-password"
:disabled="saving"
aria-label="代理 URL"
:placeholder="proxy?.endpoint ?? 'socks5h://user:password@host:1080'"
>
<template #suffix>
<BaseIconButton :label="showSecret ? '隐藏代理地址' : '显示代理地址'" :disabled="saving" @click="showSecret = !showSecret">
<EyeOff v-if="showSecret" class="size-4" />
<Eye v-else class="size-4" />
</BaseIconButton>
</template>
</BaseInput>
</BaseFormItem>
<p v-if="proxy?.accountCount && proxyUrl.trim()" class="m-0 text-cp-sm text-cp-warning-text">
将更新 {{ proxy.accountCount }} 个关联账号的出口。
</p>
</BaseForm>
<template #footer>
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton variant="secondary" :loading="saving" @click="emit('save', false)">
<template #icon>
<Save class="size-4" />
</template>
保存代理
</BaseButton>
<BaseButton variant="primary" :loading="saving" @click="emit('save', true)">
<template #icon>
<Wifi class="size-4" />
</template>
保存并测试
</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,89 @@
import { watchDebounced } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import { getProxyAccounts, removeProxyAccount } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { errorMessage } from '@/utils/async'
export function useProxyAccounts(options: {
isOpen: () => boolean
proxyId: () => string | undefined
onRemoved: () => void
}) {
const search = shallowRef('')
const removeAction = useAsyncAction()
const query = usePagedQuery({
initialPageSize: 20,
load: pagination => getProxyAccounts({
...pagination,
proxyId: options.proxyId() ?? '',
search: search.value.trim() || undefined,
}),
onError: error => toast.error(errorMessage(error, '关联账号加载失败')),
})
const pagination = computed(() => ({
currentPage: query.page.value,
pageSize: query.pageSize.value,
total: query.total.value,
}))
function load() {
if (options.isOpen() && options.proxyId())
void query.execute()
}
function setPage(page: number) {
query.page.value = page
query.items.value = []
load()
}
function setPageSize(size: number) {
query.pageSize.value = size
setPage(1)
}
async function removeAccount(accountId: string) {
const proxyId = options.proxyId()
if (!proxyId)
return false
return await removeAction.run(async () => {
await removeProxyAccount({ proxyId, accountId })
options.onRemoved()
toast.success('账号已移出当前代理,改为直连')
if (options.isOpen() && options.proxyId() === proxyId) {
query.invalidate()
query.items.value = query.items.value.filter(account => account.id !== accountId)
query.total.value = Math.max(0, query.total.value - 1)
// 复用分页查询,删除末页最后一项时自动回到有效页。
await query.execute()
}
return true
}, { errorText: '移除关联账号失败' }) ?? false
}
watch([options.isOpen, options.proxyId], () => {
// 关闭或切换代理后丢弃旧请求,避免上一条代理的账号覆盖新列表。
query.invalidate()
query.items.value = []
query.total.value = 0
query.error.value = ''
query.page.value = 1
search.value = ''
load()
}, { immediate: true })
watchDebounced(search, () => setPage(1), { debounce: 300 })
return {
accounts: query.items,
loading: query.loading,
error: query.error,
pagination,
search,
setPage,
setPageSize,
removing: removeAction.loading,
removeAccount,
}
}
+91 -131
View File
@@ -1,18 +1,15 @@
<script setup lang="ts">
import type { OutboundProxyRecord } from '@/api'
import { Eye, EyeOff, LockKeyhole, Pencil, Plus, RefreshCw, Save, Search, Trash2, Users, Wifi } from '@lucide/vue'
import { LockKeyhole, Pencil, Plus, RefreshCw, Search, Trash2, Users, Wifi } from '@lucide/vue'
import { watchDebounced } from '@vueuse/core'
import { computed, onMounted, reactive, ref, shallowRef, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { createProxy, deleteProxy, getProxies, testProxy, updateProxy } from '@/api'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BasePageHeader from '@/components/base/BasePageHeader.vue'
import BaseSwitch from '@/components/base/BaseSwitch.vue'
import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination.vue'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
@@ -21,6 +18,8 @@ import { useAsyncAction } from '@/composables/useAsyncAction'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { errorMessage } from '@/utils/async'
import { formatDateTime } from '@/utils/date'
import ProxyAccountsModal from './components/ProxyAccountsModal.vue'
import ProxyFormModal from './components/ProxyFormModal.vue'
const search = shallowRef('')
const query = usePagedQuery({
@@ -32,18 +31,15 @@ const { items: proxies, loading } = query
const pagination = computed(() => ({ currentPage: query.page.value, pageSize: query.pageSize.value, total: query.total.value }))
const columns = defineTableColumns<OutboundProxyRecord>([
{ key: 'identity', label: '代理', kind: 'identity' },
{ key: 'status', label: '测试状态', kind: 'custom', size: 'lg' },
{ key: 'exitIp', label: '出口 IP', kind: 'custom' },
{ key: 'latency', label: '耗时', kind: 'custom', size: 'sm' },
{ key: 'accounts', label: '关联账号', kind: 'custom', size: 'sm' },
{ key: 'testedAt', label: '测试时间', kind: 'datetime' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'lg' },
{ key: 'actions', label: '操作', kind: 'actions' },
])
const showForm = shallowRef(false)
const editing = shallowRef<OutboundProxyRecord | null>(null)
const form = reactive({ name: '', proxyUrl: '' })
const replaceConnection = ref(true)
const showSecret = ref(false)
const saveAction = useAsyncAction()
const { loading: saving } = saveAction
const deleteAction = useAsyncAction()
@@ -58,8 +54,6 @@ function openForm(proxy: OutboundProxyRecord | null = null) {
editing.value = proxy
form.name = proxy?.name ?? ''
form.proxyUrl = ''
replaceConnection.value = !proxy
showSecret.value = false
showForm.value = true
}
@@ -88,13 +82,14 @@ async function save(testAfter: boolean) {
return
const name = form.name.trim()
const proxyUrl = form.proxyUrl.trim()
if (!name || (replaceConnection.value && !proxyUrl)) {
if (!name || (!editing.value && !proxyUrl)) {
toast.warning('请填写代理名称和连接地址')
return
}
await saveAction.run(async () => {
// 编辑时留空保留已保存的地址和认证,不能用脱敏地址覆盖原连接。
const result = editing.value
? await updateProxy({ id: editing.value.id, revision: editing.value.revision, name, proxyUrl: replaceConnection.value ? proxyUrl : undefined })
? await updateProxy({ id: editing.value.id, revision: editing.value.revision, name, proxyUrl: proxyUrl || undefined })
: await createProxy({ name, proxyUrl })
showForm.value = false
form.proxyUrl = ''
@@ -137,7 +132,6 @@ function setPageSize(size: number) {
watch(showForm, (open) => {
if (!open) {
form.proxyUrl = ''
showSecret.value = false
}
})
watchDebounced(search, () => setPage(1), { debounce: 300 })
@@ -146,130 +140,96 @@ onMounted(() => void query.execute())
<template>
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
<BasePageHeader title="代理管理" />
<section class="mt-5 flex min-h-125 flex-1 flex-col bg-cp-bg-container p-5">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<BaseInput v-model="search" class="w-full sm:max-w-80" aria-label="搜索代理" placeholder="搜索代理名称">
<template #prefix>
<Search class="size-4" />
</template>
</BaseInput>
<div class="ml-auto flex items-center gap-2">
<BaseIconButton label="刷新代理列表" :disabled="loading" @click="query.execute()">
<RefreshCw class="size-4" />
</BaseIconButton>
<BaseButton variant="primary" @click="openForm()">
<Plus class="size-4" />新增代理
</BaseButton>
</div>
</div>
<BaseTable class="min-h-0 flex-1" :columns="columns" :rows="proxies" :loading="loading" empty-text="暂无代理">
<template #identity="{ row }">
<div class="grid min-w-0 gap-1">
<strong class="truncate text-cp text-cp-text" :title="row.name">{{ row.name }}</strong>
<span class="flex min-w-0 items-center gap-1 text-cp-xs text-cp-text-secondary">
<LockKeyhole v-if="row.hasAuthentication" class="size-3 shrink-0" aria-label="已保存代理认证" />
<span class="truncate font-mono" :title="row.endpoint">{{ row.endpoint }}</span>
</span>
</div>
</template>
<template #status="{ row }">
<div class="grid min-w-0 gap-1">
<span class="text-cp-sm font-semibold" :class="row.lastTest?.success ? 'text-cp-success' : row.lastTest ? 'text-cp-error' : 'text-cp-text-quaternary'">
{{ testingIds.has(row.id) ? '测试中' : row.lastTest?.success ? '通过' : row.lastTest ? '失败' : '未测试' }}
</span>
<span v-if="row.lastTest && !row.lastTest.success" class="truncate text-cp-xs text-cp-text-secondary" :title="row.lastTest.message">{{ row.lastTest.message }}</span>
</div>
</template>
<template #exitIp="{ row }">
<span class="break-all font-mono text-cp-xs">{{ row.lastTest?.exitIp ?? '-' }}</span>
</template>
<template #latency="{ row }">
{{ row.lastTest ? `${row.lastTest.latencyMs} ms` : '-' }}
</template>
<template #accounts="{ row }">
<BaseButton variant="ghost" :aria-label="`查看 ${row.name} 的关联账号`" @click="inspected = row; showAccounts = true">
<Users class="size-4" />{{ row.accounts.length }}
</BaseButton>
</template>
<template #testedAt="{ row }">
{{ row.lastTestAt ? formatDateTime(row.lastTestAt) : '-' }}
</template>
<template #actions="{ row }">
<div class="flex items-center gap-1">
<BaseIconButton label="测试代理" :loading="testingIds.has(row.id)" :disabled="testingIds.has(row.id)" @click="checkProxy(row)">
<Wifi class="size-4 text-cp-link" />
</BaseIconButton>
<BaseIconButton label="编辑代理" :disabled="testingIds.has(row.id)" @click="openForm(row)">
<Pencil class="size-4 text-cp-link" />
</BaseIconButton>
<BaseIconButton :label="row.accounts.length ? '代理正在被账号使用' : '删除代理'" :disabled="row.accounts.length > 0 || testingIds.has(row.id)" @click="requestDelete(row)">
<Trash2 class="size-4 text-cp-error" />
</BaseIconButton>
</div>
</template>
</BaseTable>
<BaseTablePagination :pagination="pagination" :loading="loading" @page-change="setPage" @page-size-change="setPageSize" />
</section>
<BaseModal v-model="showForm" :title="editing ? '编辑代理' : '新增代理'" size="md" :dismissible="!saving">
<div class="grid gap-5">
<BaseFormItem label="代理名称" required>
<BaseInput v-model="form.name" maxlength="100" :disabled="saving" aria-label="代理名称" />
</BaseFormItem>
<template v-if="editing">
<p class="m-0 break-all font-mono text-cp-sm text-cp-text-secondary">
{{ editing.endpoint }}
</p>
<div class="flex items-center justify-between gap-3">
<span class="text-cp text-cp-text-secondary">更换连接配置</span>
<BaseSwitch v-model="replaceConnection" label="更换连接配置" :disabled="saving" />
</div>
</template>
<BaseFormItem v-if="replaceConnection" label="代理 URL" required>
<BaseInput v-model="form.proxyUrl" :type="showSecret ? 'text' : 'password'" autocomplete="new-password" :disabled="saving" aria-label="代理 URL" placeholder="socks5h://user:password@host:1080">
<template #suffix>
<BaseIconButton :label="showSecret ? '隐藏代理地址' : '显示代理地址'" @click="showSecret = !showSecret">
<EyeOff v-if="showSecret" class="size-4" /><Eye v-else class="size-4" />
</BaseIconButton>
<BasePageHeader
class="h-17"
title="代理管理"
description="管理账号使用的代理,测试连接并查看出口 IP"
/>
<BaseCard class="mt-5 flex h-[calc(100dvh-136px)] min-h-125 flex-col">
<template #header>
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center">
<BaseInput v-model="search" class="sm:w-80" aria-label="搜索代理" placeholder="搜索代理名称...">
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</BaseInput>
</BaseFormItem>
<p v-if="editing?.accounts.length && replaceConnection" class="m-0 text-cp-sm text-cp-warning-text">
将更新 {{ editing.accounts.length }} 个关联账号的出口。
</p>
</div>
<template #footer>
<BaseButton variant="ghost" :disabled="saving" @click="showForm = false">
取消
</BaseButton>
<BaseButton variant="secondary" :loading="saving" @click="save(false)">
<Save class="size-4" />保存代理
</BaseButton>
<BaseButton variant="primary" :loading="saving" @click="save(true)">
<Wifi class="size-4" />保存并测试
</BaseButton>
<div class="flex shrink-0 items-center justify-end gap-2 sm:ml-auto">
<BaseIconButton label="刷新代理列表" :loading="loading" @click="query.execute()">
<RefreshCw class="size-4" />
</BaseIconButton>
<BaseButton variant="primary" @click="openForm()">
<template #icon>
<Plus class="size-4" />
</template>
新增代理
</BaseButton>
</div>
</div>
</template>
</BaseModal>
<template #body>
<div class="flex h-full min-h-0 flex-col">
<BaseTable class="min-h-0 flex-1" :columns="columns" :rows="proxies" :loading="loading" :empty-text="search.trim() ? '没有找到匹配的代理,请尝试其他名称' : '暂无代理,请点击新增代理添加'">
<template #identity="{ row }">
<div class="grid min-w-0 gap-1">
<strong class="truncate text-cp text-cp-text" :title="row.name">{{ row.name }}</strong>
<span class="flex min-w-0 items-center gap-1 text-cp-xs font-emphasis text-cp-text-quaternary">
<LockKeyhole v-if="row.hasAuthentication" class="size-3 shrink-0" aria-label="已保存代理认证" />
<span class="truncate font-mono" :title="row.endpoint">{{ row.endpoint }}</span>
</span>
</div>
</template>
<template #exitIp="{ row }">
<span class="break-all font-mono text-cp-xs">{{ row.lastTest?.exitIp ?? '-' }}</span>
</template>
<template #latency="{ row }">
<span v-if="testingIds.has(row.id)" class="text-cp-text-secondary">测试中</span>
<span v-else-if="row.lastTest?.success" class="tabular-nums text-cp-success">
{{ row.lastTest.latencyMs }} ms
</span>
<span v-else-if="row.lastTest" class="text-cp-error" :title="`${row.lastTest.message}(耗时 ${row.lastTest.latencyMs} ms)`">失败</span>
<span v-else class="text-cp-text-quaternary">未测试</span>
</template>
<template #accounts="{ row }">
<button type="button" class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm border-0 bg-transparent p-0 text-cp-sm text-cp-text-secondary outline-none transition-colors hover:text-cp-primary-text focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container" :aria-label="`查看 ${row.name} 的 ${row.accountCount} 个关联账号`" @click="inspected = row; showAccounts = true">
<Users class="size-3.5" aria-hidden="true" />
<span class="font-mono tabular-nums">{{ row.accountCount }}</span>
</button>
</template>
<template #testedAt="{ row }">
{{ row.lastTestAt ? formatDateTime(row.lastTestAt) : '-' }}
</template>
<template #actions="{ row }">
<div class="flex items-center gap-1">
<BaseIconButton size="sm" label="测试代理" :loading="testingIds.has(row.id)" :disabled="testingIds.has(row.id)" @click="checkProxy(row)">
<Wifi class="size-3.5 text-cp-link" />
</BaseIconButton>
<BaseIconButton size="sm" label="编辑代理" :disabled="testingIds.has(row.id)" @click="openForm(row)">
<Pencil class="size-3.5 text-cp-link" />
</BaseIconButton>
<BaseIconButton size="sm" :label="row.accountCount ? '代理正在被账号使用' : '删除代理'" :disabled="row.accountCount > 0 || testingIds.has(row.id)" @click="requestDelete(row)">
<Trash2 class="size-3.5 text-cp-error" />
</BaseIconButton>
</div>
</template>
</BaseTable>
<BaseTablePagination :pagination="pagination" :loading="loading" @page-change="setPage" @page-size-change="setPageSize" />
</div>
</template>
</BaseCard>
<ProxyFormModal
v-model="showForm"
v-model:name="form.name"
v-model:proxy-url="form.proxyUrl"
:proxy="editing"
:saving="saving"
@save="save"
/>
<BaseConfirmModal v-model="showDelete" title="删除代理" destructive :loading="deleting" @confirm="confirmDelete">
<p class="m-0">
确定删除“{{ pendingDelete?.name }}”吗?
</p>
</BaseConfirmModal>
<BaseModal v-model="showAccounts" title="关联账号" size="md">
<ul v-if="inspected?.accounts.length" class="m-0 grid list-none gap-3 p-0">
<li v-for="account in inspected.accounts" :key="account.id" class="break-all text-cp">
{{ account.name }}
</li>
</ul>
<p v-else class="m-0 text-cp-text-secondary">
暂无关联账号
</p>
<template #footer>
<RouterLink to="/accounts" class="text-cp-link">
账号管理
</RouterLink>
</template>
</BaseModal>
<ProxyAccountsModal v-model="showAccounts" :proxy="inspected" @removed="query.execute({ silent: true })" />
</div>
</template>