mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
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:
@@ -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("a),
|
||||
),
|
||||
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("a),
|
||||
),
|
||||
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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -191,6 +191,10 @@ Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 ca
|
||||
|
||||
代理独立保存和测试,通过 `outboundProxyId` 绑定账号;账号保留解析后的 URL 供 Provider 使用。
|
||||
连接配置改变时在同一事务内同步关联账号并清除旧测试结果,已绑定的代理不可删除。
|
||||
关联账号支持单独移除:在原子更新中校验当前代理 ID,只清除账号的代理 ID 和 URL,
|
||||
保留账号其他设置;提交配置版本与审计后复用快照发布流程,使后续请求使用直连。
|
||||
代理目录只携带关联数量;账号明细通过独立分页读端口按需查询,复用代理 ID 索引,
|
||||
在同一个只读快照中统计并读取有界页面,不在目录中聚合完整账号数组。
|
||||
文件及 AT/RT 导入在上游凭据交换前取得 PostgreSQL 会话级共享咨询锁,直到凭据提交后释放。
|
||||
代理修改、删除和测试结果写入通过同一资源的事务级独占锁检查,导入期间直接返回冲突。
|
||||
锁连接不占用提交所需的连接池槽位,也不持有空闲事务;每进程最多增加四个导入保护连接。
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user