feat(accounts): separate setup from account import

Share account settings across import, single-account editing, and batch editing.
Commit credentials and import settings in the same transaction. Improve modal
actions and fix the flash at the end of light/dark theme transitions.

Reauthenticate each execution against the current key policy so existing
WebSocket connections apply updated RPM, concurrency limits, and enabled state.
Add regression coverage for policy updates and unavailable snapshots.

Validation: all 1,950 backend tests, workspace Clippy, frontend lint/typecheck/build,
migration checks, Compose validation, and workflow lint passed. Completed 24 real
requests with a free account using gpt-5.6-luna to verify daily/weekly budgets,
RPM, concurrency, reconciliation, WebSocket policy updates, and budget persistence
across restarts and usage-log cleanup.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-11 03:26:48 +08:00
co-authored by Codex
parent 12ecbf574a
commit e973fc516e
42 changed files with 1068 additions and 424 deletions
@@ -18,6 +18,15 @@ pub use gateway_core::account::{
QuotaState, resolve_account_status,
};
/// 导入时统一应用的账号调度与分组设置;缺省时保留原有导入语义。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountImportSettings {
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
}
/// 账号列表排序字段。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountSortField {
@@ -16,7 +16,9 @@ use uuid::Uuid;
use super::{
AdminError, MutationActor, MutationContext, PageSize, Revision,
accounts::{AccountRecord, AccountSummary, AccountUsage, CredentialState},
accounts::{
AccountImportSettings, AccountRecord, AccountSummary, AccountUsage, CredentialState,
},
};
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
@@ -106,6 +108,7 @@ pub struct CredentialDetails {
/// Provider 正式文档批量导入命令。
pub struct ImportCredentials {
pub settings: Option<AccountImportSettings>,
pub context: MutationContext,
pub document: ProviderDocument,
}
@@ -172,6 +175,7 @@ pub struct PreparedCredentialImport {
/// Admin 交给 Store 的导入事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialImportCommit {
pub settings: Option<AccountImportSettings>,
pub prepared: PreparedCredentialImport,
}
@@ -480,6 +484,7 @@ pub enum AuthorizationCredentialCommit {
/// Admin 交给 Store 的 OAuth 原子事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct AuthorizationCommit {
pub settings: Option<AccountImportSettings>,
pub pending: PendingAuthorizationMutation,
pub credential: AuthorizationCredentialCommit,
}
@@ -514,7 +519,10 @@ impl PreparedAuthorizationCommit {
self
}
pub(crate) fn into_commit(self) -> AuthorizationCommitSettlement {
pub(crate) fn into_commit(
self,
settings: Option<AccountImportSettings>,
) -> AuthorizationCommitSettlement {
let (credential, guard) = match self.credential {
PreparedAuthorizationCredential::Create(credential) => {
(AuthorizationCredentialCommit::Create(credential), None)
@@ -529,6 +537,7 @@ impl PreparedAuthorizationCommit {
};
AuthorizationCommitSettlement {
command: AuthorizationCommit {
settings,
pending: self.pending,
credential,
},
@@ -542,7 +551,7 @@ impl PreparedAuthorizationCommit {
credential_guard,
authorization_guard,
..
} = self.into_commit();
} = self.into_commit(None);
drop(credential_guard);
if let Some(guard) = authorization_guard {
guard.abort().await?;
@@ -554,6 +563,7 @@ impl PreparedAuthorizationCommit {
/// 完成 Provider OAuth 流程。
#[derive(Clone, PartialEq, Eq)]
pub struct CompleteAuthorization {
pub settings: Option<AccountImportSettings>,
pub context: MutationContext,
pub flow_id: String,
pub callback_url: String,
@@ -222,14 +222,24 @@ async fn validate_authorization_commit(
async fn commit_authorization(
accounts: &dyn AccountStore,
prepared: PreparedAuthorizationCommit,
settings: Option<crate::model::accounts::AccountImportSettings>,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
if settings.is_some()
&& matches!(
&prepared.credential,
PreparedAuthorizationCredential::Reauthorize(_)
)
{
prepared.abort().await?;
return Err(AdminError::invalid("重新授权不能修改账号设置"));
}
let crate::model::provider_credentials::AuthorizationCommitSettlement {
command,
credential_guard,
authorization_guard,
} = prepared.into_commit();
} = prepared.into_commit(settings);
match accounts.commit_authorization(command, context).await {
Ok(result) => {
if let Some(guard) = credential_guard {
@@ -142,7 +142,13 @@ impl OpenAiService for DefaultOpenAiService {
)?;
let result = self
.accounts
.commit_credential_import(CredentialImportCommit { prepared }, &context)
.commit_credential_import(
CredentialImportCommit {
prepared,
settings: command.settings,
},
&context,
)
.await
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
self.provider
@@ -173,9 +179,10 @@ impl OpenAiService for DefaultOpenAiService {
async fn complete_authorization(
&self,
command: CompleteAuthorization,
mut command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.context.clone();
let settings = command.settings.take();
let prepared = self
.provider
.complete_authorization(command)
@@ -191,6 +198,7 @@ impl OpenAiService for DefaultOpenAiService {
let result = commit_authorization(
self.accounts.as_ref(),
prepared,
settings,
&context,
"OpenAI authorization",
)
@@ -95,7 +95,13 @@ impl XaiService for DefaultXaiService {
)?;
let result = self
.accounts
.commit_credential_import(CredentialImportCommit { prepared }, &context)
.commit_credential_import(
CredentialImportCommit {
prepared,
settings: command.settings,
},
&context,
)
.await
.map_err(|error| map_store_error(error, "xAI credential import"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
@@ -132,9 +138,10 @@ impl XaiService for DefaultXaiService {
async fn complete_authorization(
&self,
command: CompleteAuthorization,
mut command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.context.clone();
let settings = command.settings.take();
let prepared = self
.provider
.complete_authorization(command)
@@ -150,6 +157,7 @@ impl XaiService for DefaultXaiService {
let result = commit_authorization(
self.accounts.as_ref(),
prepared,
settings,
&context,
"xAI authorization",
)
@@ -441,6 +441,7 @@ pub(super) struct FakeAccountStore {
account_after_probe: Mutex<Option<AccountRecord>>,
fail_commit: Mutex<bool>,
audit_requests: Mutex<Vec<String>>,
import_settings: Mutex<Vec<Option<gateway_admin::model::accounts::AccountImportSettings>>>,
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
quota_window_queries: Mutex<Vec<AccountUsageWindowQuery>>,
}
@@ -457,6 +458,7 @@ impl FakeAccountStore {
account_after_probe: Mutex::new(None),
fail_commit: Mutex::new(false),
audit_requests: Mutex::new(Vec::new()),
import_settings: Mutex::new(Vec::new()),
quota_window_usage: Mutex::new(Vec::new()),
quota_window_queries: Mutex::new(Vec::new()),
})
@@ -466,6 +468,15 @@ impl FakeAccountStore {
*self.fail_commit.lock().expect("store failure") = true;
}
pub(super) fn import_settings(
&self,
) -> Vec<Option<gateway_admin::model::accounts::AccountImportSettings>> {
self.import_settings
.lock()
.expect("import settings")
.clone()
}
pub(super) fn audit_requests(&self) -> Vec<String> {
self.audit_requests.lock().expect("audit requests").clone()
}
@@ -672,6 +683,10 @@ impl AccountStore for FakeAccountStore {
command: CredentialImportCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialImportResult> {
self.import_settings
.lock()
.expect("import settings")
.push(command.settings);
self.record("store.commit_import");
self.record_context(context);
self.require_commit()?;
@@ -691,6 +706,10 @@ impl AccountStore for FakeAccountStore {
command: AuthorizationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
self.import_settings
.lock()
.expect("import settings")
.push(command.settings);
self.record("store.commit_authorization");
self.record_context(context);
self.require_commit()?;
@@ -2204,3 +2223,17 @@ fn store_unavailable() -> AdminStoreError {
fn unsupported() -> ProviderAdminError {
ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)
}
pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings {
gateway_admin::model::accounts::AccountImportSettings {
enabled: false,
concurrency_limit: Some(
gateway_core::account::AccountConcurrencyLimit::new(3).expect("concurrency"),
),
weight: gateway_core::account::AccountWeight::new(7).expect("weight"),
group_ids: vec![
gateway_core::routing::AccountGroupId::new("grp_00000000000000000000000000000091")
.expect("group ID"),
],
}
}
@@ -50,6 +50,7 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
services
.openai()
.import_document(ImportCredentials {
settings: Some(super::accounts::import_settings()),
context: context("import-openai"),
document: document(),
})
@@ -75,6 +76,10 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
rolling_usage: None,
}]
);
assert_eq!(
store.import_settings(),
[Some(super::accounts::import_settings())]
);
assert_eq!(store.audit_requests(), ["import-openai"]);
}
@@ -102,6 +107,7 @@ async fn openai_import_should_expose_only_explicit_public_errors_without_committ
let error = services
.openai()
.import_document(ImportCredentials {
settings: None,
context: context("import-openai-pat-failure"),
document: document(),
})
@@ -126,6 +132,7 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
let result = services
.openai()
.import_document(ImportCredentials {
settings: None,
context: context("import-openai-batch"),
document: document(),
})
@@ -169,6 +176,7 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
let result = services
.openai()
.import_document(ImportCredentials {
settings: None,
context: context("import-openai-quota-failure"),
document: document(),
})
@@ -204,6 +212,7 @@ async fn openai_authorization_create_should_observe_initial_quota() {
services
.openai()
.complete_authorization(CompleteAuthorization {
settings: Some(super::accounts::import_settings()),
context: context("oauth-start-openai-create"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
@@ -231,6 +240,10 @@ async fn openai_authorization_create_should_observe_initial_quota() {
rolling_usage: None,
}]
);
assert_eq!(
store.import_settings(),
[Some(super::accounts::import_settings())]
);
assert_eq!(store.audit_requests(), ["oauth-start-openai-create"]);
}
@@ -255,6 +268,7 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
let result = services
.openai()
.complete_authorization(CompleteAuthorization {
settings: None,
context: context("oauth-start-openai-quota-failure"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
@@ -277,6 +291,7 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
store.fail_next_commit();
let services = service(provider.clone(), store).await;
let command = || CompleteAuthorization {
settings: None,
context: context("oauth-store-retry"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
@@ -333,6 +348,7 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
services
.openai()
.import_document(ImportCredentials {
settings: None,
context: context("import-openai-error"),
document: document(),
})
@@ -377,6 +393,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
let result = services
.openai()
.complete_authorization(CompleteAuthorization {
settings: None,
context: context("oauth-complete-openai"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
@@ -428,3 +445,34 @@ fn deletion(account_id: &str) -> CredentialDeletion {
account_ids: vec![ProviderAccountId::new(account_id).expect("account ID")],
}
}
#[tokio::test]
async fn reauthorization_with_import_settings_releases_claim_without_committing() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider, store.clone()).await;
services
.openai()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("reauthorization-settings"),
name: "existing account".to_owned(),
reauthorization: Some(ProviderAccountId::new("acct_test").expect("account ID")),
})
.await
.expect("start reauthorization");
let error = services
.openai()
.complete_authorization(CompleteAuthorization {
settings: Some(super::accounts::import_settings()),
context: context("reauthorization-settings"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
})
.await
.expect_err("reauthorization must preserve settings");
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid);
assert!(!recorded(&events).contains(&"store.commit_authorization"));
assert!(store.import_settings().is_empty());
}
@@ -20,6 +20,7 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
services
.xai()
.import_document(ImportCredentials {
settings: Some(super::accounts::import_settings()),
context: context("import-xai"),
document: document(),
})
@@ -42,6 +43,10 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
rolling_usage: None,
}]
);
assert_eq!(
store.import_settings(),
[Some(super::accounts::import_settings())]
);
assert_eq!(store.audit_requests(), ["import-xai"]);
}
@@ -56,6 +61,7 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
services
.xai()
.import_document(ImportCredentials {
settings: None,
context: context("import-xai-batch"),
document: document(),
})
@@ -90,6 +96,7 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
let result = services
.xai()
.import_document(ImportCredentials {
settings: None,
context: context("import-xai-quota-failure"),
document: document(),
})
@@ -18,16 +18,51 @@ impl AccountProvider {
}
}
/// 导入统一设置,复用编辑账号的调度和分组约束。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportSettingsRequest {
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
pub weight: u64,
pub group_ids: Vec<String>,
}
impl AccountImportSettingsRequest {
fn validate(&self) -> Result<(), WireValidationError> {
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
validate_wire_group_ids(&self.group_ids)?;
Ok(())
}
fn into_settings(
self,
) -> Result<gateway_admin::model::accounts::AccountImportSettings, WireValidationError> {
Ok(gateway_admin::model::accounts::AccountImportSettings {
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
group_ids: validate_wire_group_ids(&self.group_ids)?,
})
}
}
/// Provider-owned 账号导入请求;公共 API 不解释 `data` 内部字段。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportRequest {
pub settings: Option<AccountImportSettingsRequest>,
pub provider: String,
pub data: Value,
}
impl AccountImportRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if let Some(settings) = &self.settings {
settings.validate()?;
}
AccountProvider::parse(&self.provider)?;
if !self.data.is_object()
|| serde_json::to_vec(&self.data)
@@ -47,6 +82,10 @@ impl AccountImportRequest {
Ok((
provider,
ImportCredentials {
settings: self
.settings
.map(AccountImportSettingsRequest::into_settings)
.transpose()?,
context,
document: provider_document(self.data, "data")?,
},
@@ -99,6 +138,7 @@ impl StartAccountAuthorizationRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CompleteAccountAuthorizationRequest {
pub settings: Option<AccountImportSettingsRequest>,
pub provider: String,
pub flow_id: String,
pub callback_url: String,
@@ -106,6 +146,9 @@ pub struct CompleteAccountAuthorizationRequest {
impl CompleteAccountAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if let Some(settings) = &self.settings {
settings.validate()?;
}
let provider = AccountProvider::parse(&self.provider)?;
match provider {
AccountProvider::OpenAi => {
@@ -130,6 +173,10 @@ impl CompleteAccountAuthorizationRequest {
Ok((
provider,
CompleteAuthorization {
settings: self
.settings
.map(AccountImportSettingsRequest::into_settings)
.transpose()?,
context,
flow_id: self.flow_id,
callback_url: self.callback_url,
@@ -756,3 +756,60 @@ mod actions {
);
}
}
mod import_settings {
use gateway_api::admin::accounts::{AccountImportRequest, CompleteAccountAuthorizationRequest};
use serde_json::json;
#[test]
fn import_and_oauth_apply_the_same_settings_validation() {
for (field, value) in [
("concurrencyLimit", json!(0)),
("concurrencyLimit", json!(4_294_967_296_u64)),
("weight", json!(0)),
("weight", json!(101)),
("groupIds", json!(["invalid-group"])),
] {
let mut settings =
json!({"enabled": false, "concurrencyLimit": null, "weight": 1, "groupIds": []});
settings[field] = value;
let import: AccountImportRequest = serde_json::from_value(
json!({"provider": "openai", "data": {}, "settings": settings}),
)
.expect("import request");
let oauth: CompleteAccountAuthorizationRequest = serde_json::from_value(json!({"provider": "xai", "flowId": "flow-test", "callbackUrl": "code", "settings": settings})).expect("OAuth request");
assert_eq!(
import.validate().expect_err("invalid settings").field(),
field
);
assert_eq!(
oauth.validate().expect_err("invalid settings").field(),
field
);
}
}
#[test]
fn import_settings_require_a_complete_explicit_configuration() {
let settings =
json!({"enabled": false, "concurrencyLimit": null, "weight": 100, "groupIds": []});
let request: AccountImportRequest =
serde_json::from_value(json!({"provider": "openai", "data": {}, "settings": settings}))
.expect("settings");
assert!(request.validate().is_ok());
for field in ["enabled", "concurrencyLimit", "weight", "groupIds"] {
let mut incomplete = settings.clone();
incomplete
.as_object_mut()
.expect("settings object")
.remove(field);
assert!(
serde_json::from_value::<AccountImportRequest>(
json!({"provider": "openai", "data": {}, "settings": incomplete})
)
.is_err(),
"missing {field}"
);
}
}
}
@@ -334,9 +334,18 @@ impl DefaultExecutionService {
&self,
mut request: PendingStartExecution,
) -> Result<StartedExecution, GatewayError> {
request.client.policy.authorize().map_err(|_| {
GatewayError::new(GatewayErrorKind::PolicyDenied, "client API key is disabled")
})?;
// 长连接每次执行都重新鉴权并冻结当前策略,确保限额和授权变更对新请求生效。
request.client = self
.authenticate(request.client.policy.plaintext_key().expose_for_auth())
.map_err(|error| match error {
ClientAuthenticationError::InvalidKey => {
GatewayError::new(GatewayErrorKind::Unauthorized, "client API key is invalid")
}
ClientAuthenticationError::SnapshotUnavailable => GatewayError::new(
GatewayErrorKind::Internal,
"runtime snapshot is unavailable",
),
})?;
let started_at = SystemTime::now();
let deadline_at = started_at
.checked_add(MODEL_REQUEST_DEADLINE)
@@ -15,14 +15,16 @@ use gateway_core::error::GatewayError;
#[derive(Default)]
struct Admissions {
active: Arc<AtomicBool>,
limits: Mutex<Vec<RateLimits>>,
}
impl ClientAdmissionPort for Admissions {
fn admit(
&self,
_: ClientAdmissionRequest,
request: ClientAdmissionRequest,
) -> BoxFuture<'_, Result<ClientAdmissionDecision, ClientAdmissionError>> {
Box::pin(async {
Box::pin(async move {
self.limits.lock().unwrap().push(request.limits);
assert!(!self.active.swap(true, Ordering::SeqCst));
Ok(ClientAdmissionDecision::Granted)
})
@@ -127,6 +129,72 @@ fn budget_rejection_releases_client_concurrency_without_creating_a_charge() {
}
}
#[test]
fn reused_client_uses_updated_limits_for_each_execution() {
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
let admissions = Arc::new(Admissions::default());
let service = DefaultExecutionService::new(
snapshots.clone(),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::default(),
admissions.clone(),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
);
let client = service.authenticate("sk_start_test").unwrap();
let limited = RateLimits {
max_concurrency: 1,
requests_per_minute: 1,
};
for (revision, limits) in [(2, limited), (3, RateLimits::unlimited())] {
snapshots.publish(start_snapshot_with_policy(revision, true, limits));
let mut next = request(&service, ClientTransport::WebSocket);
next.client = client.clone();
let started = block_on(service.start(next)).expect("new execution");
block_on(started.session.detach_finalize());
}
assert_eq!(
*admissions.limits.lock().unwrap(),
vec![limited, RateLimits::unlimited()]
);
}
#[test]
fn reused_client_cannot_start_after_key_disable_or_snapshot_suspension() {
for suspend in [false, true] {
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
let admissions = Arc::new(Admissions::default());
let service = DefaultExecutionService::new(
snapshots.clone(),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::default(),
admissions.clone(),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
);
let next = request(&service, ClientTransport::WebSocket);
if suspend {
snapshots.suspend();
} else {
snapshots.publish(start_snapshot_with_policy(
2,
false,
RateLimits::unlimited(),
));
}
let result = block_on(service.start(next));
let expected = if suspend {
GatewayErrorKind::Internal
} else {
GatewayErrorKind::Unauthorized
};
assert!(matches!(result, Err(error) if error.kind() == expected));
assert!(admissions.limits.lock().unwrap().is_empty());
}
}
#[test]
fn cancellation_and_pre_send_failure_settle_zero_and_release_concurrency_for_all_transports() {
let admissions = Arc::new(Admissions::default());
@@ -1082,11 +1150,15 @@ fn client_snapshot() -> RuntimeSnapshot {
}
fn start_snapshot() -> RuntimeSnapshot {
start_snapshot_with_policy(1, true, RateLimits::unlimited())
}
fn start_snapshot_with_policy(revision: u64, enabled: bool, limits: RateLimits) -> RuntimeSnapshot {
let provider = ProviderKind::new("openai").expect("provider kind");
let capabilities =
ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000));
RuntimeSnapshot::new(
ConfigRevision::new(1).expect("config revision"),
ConfigRevision::new(revision).expect("config revision"),
AccountSelectionPolicy::new(
RotationStrategy::Smart,
std::num::NonZeroU32::new(1).expect("concurrency"),
@@ -1102,8 +1174,8 @@ fn start_snapshot() -> RuntimeSnapshot {
ClientApiKeyId::new("key_start_test").expect("client API key ID"),
PlaintextClientApiKey::new("sk_start_test").expect("plaintext client API key"),
account_scope(&provider, "acct_start"),
true,
RateLimits::unlimited(),
enabled,
limits,
)],
)
.expect("start snapshot")
@@ -213,6 +213,7 @@ impl PgAdminAccountStore {
async fn commit_prepared_import(
&self,
prepared: PreparedCredentialImport,
settings: Option<AccountImportSettings>,
context: &MutationContext,
action: &str,
) -> AdminStoreResult<CredentialImportResult> {
@@ -223,9 +224,15 @@ impl PgAdminAccountStore {
.map(prepared_account)
.collect::<StoreResult<Vec<_>>>()
.map_err(|error| admin_store_error(ENTITY, error))?;
let mut changed_fields = vec!["credentials".to_owned()];
if settings.is_some() {
changed_fields
.extend(["enabled", "concurrency_limit", "weight", "group_ids"].map(str::to_owned));
}
let imported = self
.accounts
.import_provider_accounts(ImportProviderAccounts {
settings,
scope: ProviderAccountAdminScope {
provider_kind: provider_kind.clone(),
},
@@ -235,7 +242,7 @@ impl PgAdminAccountStore {
action,
"provider_account",
&provider_kind,
vec!["credentials".to_owned()],
changed_fields,
),
})
.await
@@ -615,8 +622,13 @@ impl AccountStore for PgAdminAccountStore {
command: CredentialImportCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialImportResult> {
self.commit_prepared_import(command.prepared, context, "import_document")
.await
self.commit_prepared_import(
command.prepared,
command.settings,
context,
"import_document",
)
.await
}
async fn commit_authorization(
@@ -635,6 +647,7 @@ impl AccountStore for PgAdminAccountStore {
provider_kind: credential.provider_kind.clone(),
credentials: vec![credential],
},
command.settings,
context,
"authorize",
)
@@ -666,6 +679,13 @@ impl AccountStore for PgAdminAccountStore {
})
}
AuthorizationCredentialCommit::Reauthorize(prepared) => {
if command.settings.is_some() {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"reauthorization cannot change account settings",
));
}
self.commit_prepared_rotation(prepared, context, "reauthorize")
.await
}
@@ -9,12 +9,13 @@ use gateway_admin::{
MutationContext, Revision as AdminRevision,
account_groups::AccountGroupRef,
accounts::{
AccountCost, AccountGroupFilter, AccountListQuery as AdminAccountListQuery,
AccountModelUsage, AccountPage, AccountPageItem, AccountRecord, AccountRequestBucket,
AccountSort as AdminAccountSort, AccountSortField as AdminAccountSortField,
AccountSummary, AccountUpdateResult, AccountUsage, AccountUsageWindowQuery,
AccountUsageWindowResult, AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts,
SortDirection as AdminSortDirection, UpdateAccount,
AccountCost, AccountGroupFilter, AccountImportSettings,
AccountListQuery as AdminAccountListQuery, AccountModelUsage, AccountPage,
AccountPageItem, AccountRecord, AccountRequestBucket, AccountSort as AdminAccountSort,
AccountSortField as AdminAccountSortField, AccountSummary, AccountUpdateResult,
AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult, AccountsUpdateResult,
BatchUpdateAccounts, DeleteAccounts, SortDirection as AdminSortDirection,
UpdateAccount,
},
observability::{
CostCoverage as AdminCostCoverage, DecimalAmount as AdminDecimalAmount, TimeRange,
@@ -448,6 +448,29 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
account_ids
.push(upsert_provider_account_in_transaction(&mut transaction, account).await?);
}
if let Some(settings) = &command.settings {
let unique_ids = account_ids
.iter()
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
update_provider_accounts_scheduling_in_transaction(
&mut transaction,
&unique_ids,
settings.enabled,
settings.concurrency_limit,
settings.weight,
None,
)
.await?;
replace_account_group_assignments_in_transaction(
&mut transaction,
&unique_ids,
&settings.group_ids,
)
.await?;
}
append_admin_audit_event_in_transaction(&mut transaction, command.audit, revision)
.await?;
Ok(ProviderAccountAdminImport {
@@ -846,7 +869,7 @@ pub(crate) async fn update_provider_accounts_scheduling_in_transaction(
) -> StoreResult<()> {
let updated = sqlx::query_scalar::<_, String>(
"update provider_accounts
set enabled = $2, concurrency_limit = $3, weight = $4, updated_at = now(),
set enabled = $2, concurrency_limit = $3, weight = $4, updated_at = greatest(now(), updated_at),
outbound_proxy_url = case when $5 then $6 else outbound_proxy_url end
where id = any($1::text[])
returning id",
@@ -949,7 +972,7 @@ fn validate_batch_update_account_ids(account_ids: &[String]) -> StoreResult<()>
Ok(())
}
fn validate_batch_update_group_ids(group_ids: &[AccountGroupId]) -> StoreResult<()> {
pub(super) fn validate_batch_update_group_ids(group_ids: &[AccountGroupId]) -> StoreResult<()> {
const MAX_BATCH_UPDATE_GROUPS: usize = 1000;
if group_ids.len() > MAX_BATCH_UPDATE_GROUPS {
return Err(invalid("account batch update contains too many group IDs"));
@@ -232,6 +232,7 @@ impl NewProviderAccount {
#[derive(Clone)]
pub struct ImportProviderAccounts {
pub settings: Option<AccountImportSettings>,
pub scope: ProviderAccountAdminScope,
pub accounts: Vec<NewProviderAccount>,
pub audit: AdminAuditEvent,
@@ -263,6 +264,9 @@ impl fmt::Debug for ImportProviderAccounts {
impl ImportProviderAccounts {
pub fn validate(&self) -> StoreResult<()> {
if let Some(settings) = &self.settings {
repository::validate_batch_update_group_ids(&settings.group_ids)?;
}
self.scope.validate()?;
if self.accounts.is_empty() || self.accounts.len() > MAX_ADMIN_IMPORT_BATCH {
return Err(invalid(
@@ -1545,6 +1545,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
};
let imported = repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
scope: scope.clone(),
accounts: vec![account("acct_admin_upsert", "user-admin-upsert")],
audit: audit("audit_admin_upsert_create", "import", "acct_admin_upsert"),
@@ -1567,6 +1568,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
updated.credential_state = CredentialState::Banned;
let imported = repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
scope: scope.clone(),
accounts: vec![updated],
audit: audit("audit_admin_upsert_update", "import", "acct_admin_upsert"),
@@ -1601,6 +1603,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
scope,
accounts: vec![account("acct_admin_upsert", "another-user")],
audit: audit("audit_admin_upsert_rebind", "import", "acct_admin_upsert"),
@@ -1638,6 +1641,12 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser
let result = admin_account_store(&database.pool)
.commit_authorization(
AuthorizationCommit {
settings: Some(gateway_admin::model::accounts::AccountImportSettings {
enabled: false,
concurrency_limit: None,
weight: gateway_core::account::AccountWeight::new(9).expect("weight"),
group_ids: Vec::new(),
}),
pending: PendingAuthorizationMutation::new(
provider_kind.clone(),
AuthorizationMutationTarget::Create {
@@ -1679,6 +1688,8 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser
),
("acct_authorization_existing", Some(2)),
);
let settings: (bool, Option<i64>, i16) = sqlx::query_as("select enabled, concurrency_limit, weight from provider_accounts where id = 'acct_authorization_existing'").fetch_one(&database.pool).await.expect("OAuth settings");
assert_eq!(settings, (false, None, 9));
database.close().await;
}
@@ -1872,6 +1883,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
let imported = repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
scope: scope.clone(),
accounts: vec![
account("acct_admin_a", "user-admin-a"),
@@ -1899,6 +1911,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
scope: scope.clone(),
accounts: vec![
account("acct_admin_transient", "user-admin-transient"),
@@ -2087,6 +2100,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi
};
let imported = repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
scope: scope.clone(),
accounts: vec![account("acct_grouped_import", "user-grouped-import")],
audit: audit("audit_grouped_import", "import", "acct_grouped_import"),
@@ -2120,6 +2134,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi
.expect("set account scheduling before reimport");
repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
scope: scope.clone(),
accounts: vec![account("acct_reimport_candidate", "user-grouped-import")],
audit: audit("audit_grouped_reimport", "import", "acct_grouped_import"),
@@ -2187,6 +2202,7 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
};
repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
scope: scope.clone(),
accounts: vec![account("acct_rotation_quota", "user-rotation-quota")],
audit: audit(
@@ -2328,6 +2344,7 @@ async fn disabled_account_preserves_user_state_during_refresh_writes() {
disabled.credential_state = CredentialState::Expired;
repository
.import_provider_accounts(ImportProviderAccounts {
settings: None,
scope: scope.clone(),
accounts: vec![disabled],
audit: audit(
@@ -2616,3 +2633,103 @@ async fn proxy_edit_preserves_an_inflight_token_refresh() {
"http://127.0.0.1:18080/"
);
}
#[tokio::test]
async fn account_import_settings_apply_atomically_to_new_and_existing_identities() {
use gateway_admin::model::accounts::AccountImportSettings;
use gateway_core::account::{AccountConcurrencyLimit, AccountWeight};
const GROUP_ID: &str = "grp_00000000000000000000000000000091";
let Some(database) = TestDatabase::create("import_settings").await else {
return;
};
let repository = PgProviderAccountRepository::new(database.pool.clone());
repository
.insert_provider_account(account("acct_existing_settings", "existing-settings-user"))
.await
.expect("seed account");
sqlx::query(
"insert into account_groups (id, name, color, created_at, updated_at) values ($1, 'Import settings', '#2563EBFF', now(), now())",
)
.bind(GROUP_ID)
.execute(&database.pool)
.await
.expect("seed group");
let settings = AccountImportSettings {
enabled: false,
concurrency_limit: Some(AccountConcurrencyLimit::new(3).expect("concurrency")),
weight: AccountWeight::new(7).expect("weight"),
group_ids: vec![AccountGroupId::new(GROUP_ID).expect("group ID")],
};
let result = repository
.import_provider_accounts(ImportProviderAccounts {
settings: Some(settings.clone()),
scope: ProviderAccountAdminScope {
provider_kind: "openai".to_owned(),
},
accounts: vec![
account("acct_new_settings", "new-settings-user"),
account("acct_existing_candidate", "existing-settings-user"),
account("acct_duplicate_candidate", "existing-settings-user"),
],
audit: audit("audit_import_settings", "import", "provider_accounts"),
})
.await
.expect("import with settings");
assert_eq!(result.config_revision.get(), 2);
for id in ["acct_new_settings", "acct_existing_settings"] {
let row: (bool, Option<i64>, i16) = sqlx::query_as(
"select enabled, concurrency_limit, weight from provider_accounts where id = $1",
)
.bind(id)
.fetch_one(&database.pool)
.await
.expect("saved settings");
assert_eq!(row, (false, Some(3), 7));
assert_eq!(account_group_ids(&database.pool, id).await, [GROUP_ID]);
}
let before = repository
.load_provider_account("acct_existing_settings")
.await
.expect("existing account");
let failed = repository
.import_provider_accounts(ImportProviderAccounts {
settings: Some(AccountImportSettings {
group_ids: vec![
AccountGroupId::new("grp_00000000000000000000000000000092")
.expect("missing group"),
],
..settings
}),
scope: ProviderAccountAdminScope {
provider_kind: "openai".to_owned(),
},
accounts: vec![
account("acct_rollback_settings", "rollback-settings-user"),
account("acct_rollback_candidate", "existing-settings-user"),
],
audit: audit(
"audit_import_settings_failed",
"import",
"provider_accounts",
),
})
.await;
assert!(failed.is_err());
assert_eq!(current_revision(&database.pool).await, 2);
assert_eq!(
account_count(&database.pool, "acct_rollback_settings").await,
0
);
assert_eq!(
repository
.load_provider_account("acct_existing_settings")
.await
.expect("unchanged account"),
before
);
assert_eq!(
account_group_ids(&database.pool, "acct_existing_settings").await,
[GROUP_ID]
);
database.close().await;
}
@@ -443,6 +443,7 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
let error = bundle
.admin_provider()
.complete_authorization(CompleteAuthorization {
settings: None,
context: MutationContext {
actor: MutationActor::AdminSession {
admin_user_id: "different-owner".to_owned(),
@@ -217,6 +217,7 @@ async fn xai_admin_provider_restores_full_pending_envelope_and_binds_owner() {
let wrong_owner = bundle
.admin_provider()
.complete_authorization(CompleteAuthorization {
settings: None,
context: MutationContext {
actor: MutationActor::AdminSession {
admin_user_id: "different-owner".to_owned(),
@@ -237,6 +238,7 @@ async fn xai_admin_provider_restores_full_pending_envelope_and_binds_owner() {
let invalid_callback = bundle
.admin_provider()
.complete_authorization(CompleteAuthorization {
settings: None,
context: owner,
flow_id: flow_id.to_owned(),
callback_url: format!(
@@ -259,6 +261,7 @@ async fn xai_admin_provider_restores_full_pending_envelope_and_binds_owner() {
let mismatched_envelope = bundle
.admin_provider()
.complete_authorization(CompleteAuthorization {
settings: None,
context: MutationContext {
actor: MutationActor::AdminSession {
admin_user_id: "admin-owner".to_owned(),
+11 -4
View File
@@ -171,7 +171,7 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
| `GET` | `/api/admin/accounts` | `page`、`pageSize`、`provider`、`groupId`、`search`、`status`、排序字段 | 分页查询账号与汇总 |
| `GET` | `/api/admin/accounts/detail` | `accountId` | 查询账号详情、额度和本地用量 |
| `GET` | `/api/admin/accounts/export` | `accountIds`、`confirm=export_sensitive_accounts` | 显式导出最多 200 个账号的敏感 Provider 文档 |
| `POST` | `/api/admin/accounts/import` | `{ provider, data }` | 导入或按上游身份更新账号;新账号保持未分组,已有账号保留所属分组 |
| `POST` | `/api/admin/accounts/import` | `{ provider, data, settings? }` | 导入或按上游身份更新账号,可同时应用调度与分组设置 |
| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 |
| `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 管理员显式清除该账号的本地错误/额度/cooldown 事实并重新启用,不访问上游 |
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token |
@@ -187,7 +187,7 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
| `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖 cache |
| `GET` | `/api/admin/accounts/connection-test` | `accountId`、`modelId` | 通过 SSE 返回实时连接测试事件,不作为业务 Responses 用量记录 |
| `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, accountId?, outboundProxyUrl? }` | 创建 OpenAI 或 xAI OAuth flow;`accountId` 表示重新授权 |
| `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl }` | 消费 OAuth callback;新账号保持未分组,重新授权保留所属分组 |
| `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl, settings? }` | 消费 OAuth callback;首次授权可附带账号设置,重新授权保留原设置 |
账号列表支持以下稳定值:
@@ -268,8 +268,14 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token 写入日志、issue、fixture 或文档。
账号导入与 OAuth complete 不接收 `groupIds`。首次创建的账号保持未分组;按既有上游身份重新导入、
重新授权以及普通 credential refresh/rotation 均保留已有分组。分组关系只通过账号编辑维护。
账号导入与首次 OAuth complete 可附带 `settings: { enabled, concurrencyLimit, weight, groupIds }`。
提供 `settings` 时四项均必填,`concurrencyLimit: null` 继承运行参数,否则为 1–4294967295 的整数;
`weight` 为 1–100,`groupIds` 为完整分组集合。设置应用于本次导入的全部账号,包括匹配到的已有账号,
与凭据在同一事务内提交;分组不存在时整次回滚。省略 `settings` 时新账号使用默认设置并保持未分组,
已有账号保留原有分组、权重与并发设置。重新授权不接受 `settings`,普通 credential refresh/rotation 也保留账号设置。
管理端先配置账号设置,再选择 OAuth、AT/RT 或账号文件完成导入。返回设置保留输入;更改出站配置会使
旧 OAuth 链接失效。文件中显式的出站配置优先于表单代理,未指定时使用表单代理。
账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。
OpenAI 的 CPR 导出保持 OAuth 账号的既有 token 与过期时间字段。
@@ -450,6 +456,7 @@ PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用
日窗口按北京时间零点重置;周窗口从首次准入当天零点起持续七天,到期后在下一次使用时重新开启。
费用按请求完成时间归属窗口。并发按同一 Key 的执行中请求累计,包含 SSE 与每个 WebSocket
`response.create`;空闲连接不占名额,内部重试不重复占用。
修改 Key 策略对既有 WebSocket 连接的下一次请求同样生效,已开始的请求保持原有快照。
任一已结算金额达到限额后拒绝新请求,已准入请求可完成并使金额超过阈值。
HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly_budget_exceeded`,
+4 -1
View File
@@ -234,7 +234,9 @@ Client Key 与账号分组形成授权范围:
- 分组可以包含多个 Provider,账号也可以属于多个分组。
账号选择综合启停状态、credential/quota 事实、Redis cooldown、并发上限、权重、请求间隔和会话亲和。
账号编辑在一个事务中替换完整调度事实;导入、重新授权和后台刷新不会覆盖已有分组、权重或并发设置。
账号编辑在一个事务中替换完整调度事实。导入与首次 OAuth 可携带统一账号设置,由 Admin 传递给 Store,
与凭据在同一事务中提交;Provider 仍独占凭据解析。未附带设置的导入、重新授权和后台刷新保留已有分组、
权重与并发设置。管理端导入和账号编辑共用设置表单,凭据输入独立于设置。
Continuation 仍受原请求的 Client Key、账号范围、Provider 和发送/交付边界约束:
@@ -255,6 +257,7 @@ Core 负责准入与结算时序,Store 持久化费用账本,Admin 负责限
费用归属请求完成时间,跨日请求计入完成日,延迟写入或核账仍保留原完成时间。
- 准入检查已结算金额,不预占未来费用。达到任一金额阈值后拒绝新请求,已准入请求仍可完成并使总额超过阈值。
- SSE 持有并发名额直到终态;WebSocket 每个 `response.create` 独立准入,空闲连接不占名额。
Core 在每次请求开始时重新鉴权并冻结当前策略,既有连接也应用已发布的限额与授权范围变更。
换号和内部重试复用同一名额,Key 与账号并发上限同时生效;预算拒绝或启动失败释放名额。
每次计费请求在发送上游前持久化待结算记录。完成、失败、取消和断连使用同一结算路径,以网关请求 ID
+9
View File
@@ -312,7 +312,15 @@ interface AccountDeleteParams {
accountIds: string[]
}
interface AccountImportSettings {
enabled: boolean
concurrencyLimit: number | null
weight: number
groupIds: string[]
}
interface AccountImportParam {
settings?: AccountImportSettings
provider: string
data: unknown
}
@@ -325,6 +333,7 @@ interface AccountOAuthStartParam {
}
interface AccountOAuthCompleteParam {
settings?: AccountImportSettings
provider: string
flowId: string
callbackUrl: string
+7 -18
View File
@@ -156,22 +156,18 @@ export const useThemeStore = defineStore(
return
}
let cleaned = false
const cleanupTransition = (): void => {
if (cleaned)
return
cleaned = true
requestAnimationFrame(() => {
let activeAnimation: Animation | undefined
void transition.finished
.catch(() => undefined)
.finally(() => {
// 旧快照移除后再取消裁剪,避免深色图层在最后一帧重新铺满页面。
activeAnimation?.cancel()
document.documentElement.classList.remove('theme-view-transition-shrink')
themeTransitioning.value = false
})
}
const cleanupTimer = window.setTimeout(cleanupTransition, 1200)
void transition.finished.catch(() => undefined)
let activeAnimation: Animation | undefined
void transition.ready
.then(() => {
const animation = document.documentElement.animate(
activeAnimation = document.documentElement.animate(
{
clipPath: shrinkingDarkLayer
? [
@@ -192,15 +188,8 @@ export const useThemeStore = defineStore(
: '::view-transition-new(root)',
},
)
activeAnimation = animation
return animation.finished.catch(() => undefined)
})
.catch(() => undefined)
.finally(() => {
activeAnimation?.cancel()
window.clearTimeout(cleanupTimer)
cleanupTransition()
})
}
function runFallbackThemeTransition(theme: ResolvedTheme): void {
@@ -64,7 +64,7 @@ const description = computed(() => props.group
</BaseForm>
<template #footer>
<BaseButton variant="ghost" :disabled="saving" @click="open = false">
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton
@@ -1,13 +1,9 @@
<script setup lang="ts">
import type { AccountGroup } from '@/api'
import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseSwitch from '@/components/base/BaseSwitch.vue'
import AccountProxyField from './AccountProxyField.vue'
import AccountSettingsFields from './AccountSettingsFields.vue'
defineProps<{
selectedCount: number
@@ -37,54 +33,20 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
size="md"
:dismissible="!saving"
>
<div class="grid gap-5">
<div class="flex min-h-6 items-center justify-between gap-3">
<span class="text-cp leading-none font-medium text-cp-text-secondary">调度</span>
<BaseSwitch
v-model="enabled"
label="切换所选账号调度"
:disabled="saving"
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="并发限制">
<BaseInput
v-model="concurrencyLimit"
aria-label="所选账号并发限制"
type="number"
min="1"
max="4294967295"
placeholder="留空使用默认值"
:disabled="saving"
/>
</BaseFormItem>
<BaseFormItem label="权重">
<BaseInput
v-model="weight"
aria-label="所选账号调度权重"
type="number"
min="1"
max="100"
placeholder="越高越优先,最大 100"
:disabled="saving"
/>
</BaseFormItem>
</div>
<AccountProxyField v-model:mode="proxyMode" v-model:url="proxyUrl" :disabled="saving" />
<BaseFormItem label="所属分组">
<AccountGroupCheckboxGrid
v-model="selectedGroupIds"
:groups="groups"
:loading="groupsLoading"
:disabled="saving"
/>
</BaseFormItem>
</div>
<AccountSettingsFields
v-model:enabled="enabled"
v-model:concurrency-limit="concurrencyLimit"
v-model:weight="weight"
v-model:selected-group-ids="selectedGroupIds"
v-model:proxy-mode="proxyMode"
v-model:proxy-url="proxyUrl"
:groups="groups"
:groups-loading="groupsLoading"
:disabled="saving"
/>
<template #footer>
<BaseButton variant="ghost" :disabled="saving" @click="open = false">
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton
@@ -218,7 +218,7 @@ function connectionLogClass(tone: string) {
</div>
<template #footer>
<BaseButton variant="ghost" @click="open = false">
<BaseButton variant="secondary" @click="open = false">
关闭
</BaseButton>
<BaseButton
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { Upload } from '@lucide/vue'
import { useFileDialog } from '@vueuse/core'
import { onScopeDispose, ref } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
defineProps<{
label: string
placeholder: string
uploadable: boolean
disabled: boolean
}>()
const text = defineModel<string>({ required: true })
const fileError = ref('')
const { open: openFile, onChange } = useFileDialog({ accept: 'application/json,.json', multiple: false, reset: true })
let readVersion = 0
onScopeDispose(() => {
readVersion += 1
})
onChange(async (files) => {
const file = files?.[0]
if (!file)
return
const version = ++readVersion
fileError.value = ''
try {
const contents = await file.text()
if (version !== readVersion)
return
text.value = contents
}
catch {
if (version === readVersion)
fileError.value = '文件读取失败,请重新选择'
}
})
function updateText(value: string) {
text.value = value
readVersion += 1
fileError.value = ''
}
</script>
<template>
<BaseFormItem :label="label" required :error="fileError || undefined">
<template v-if="uploadable" #extra>
<BaseButton size="sm" :disabled="disabled" @click="openFile()">
<template #icon>
<Upload class="size-3.5" aria-hidden="true" />
</template>
上传文件
</BaseButton>
</template>
<BaseTextarea
:model-value="text"
:aria-label="label"
:rows="9"
:placeholder="placeholder"
:disabled="disabled"
@update:model-value="updateText"
/>
</BaseFormItem>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { Copy, KeyRound } from '@lucide/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 BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
import { useCopyText } from '@/composables/useCopyText'
defineProps<{
authUrl: string
panelTitle: string
panelDescription: string
callbackLabel: string
callbackPlaceholder: string
loading: boolean
disabled: boolean
}>()
const emit = defineEmits<{ regenerate: [] }>()
const callback = defineModel<string>({ required: true })
const copyWithToast = useCopyText()
</script>
<template>
<div class="flex flex-col gap-4">
<div class="rounded-cp bg-cp-fill-quaternary px-4 py-3">
<div class="flex items-start gap-3">
<div
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-cp bg-cp-bg-container text-cp-primary-text"
>
<KeyRound class="size-4" />
</div>
<div class="min-w-0 flex-1">
<p class="m-0 text-cp font-bold text-cp-text">
{{ panelTitle }}
</p>
<p class="m-0 mt-1 text-cp-sm leading-[1.55] font-medium text-cp-text-secondary">
{{ panelDescription }}
</p>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<BaseButton
variant="secondary"
:loading="loading"
:disabled="disabled"
@click="emit('regenerate')"
>
{{ authUrl ? '重新生成授权链接' : '生成授权链接' }}
</BaseButton>
</div>
<BaseForm v-if="authUrl">
<BaseFormItem label="授权链接">
<template #extra>
<BaseIconButton
variant="secondary"
size="sm"
title="复制链接"
label="复制链接"
:disabled="disabled"
@click="copyWithToast(authUrl, { successText: '授权链接已复制' })"
>
<Copy class="size-3.5" />
</BaseIconButton>
</template>
<BaseScrollbar max-height="92px">
<div class="rounded-cp bg-[var(--cp-input-bg)] px-3.5 py-3 shadow-cp-tertiary">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-sm leading-[1.6] font-emphasis text-cp-text-secondary"
v-text="authUrl"
/>
</div>
</BaseScrollbar>
</BaseFormItem>
</BaseForm>
<BaseForm>
<BaseFormItem :label="callbackLabel" required>
<BaseTextarea
v-model="callback"
:aria-label="callbackLabel"
:rows="4"
:placeholder="callbackPlaceholder"
:disabled="disabled"
/>
</BaseFormItem>
</BaseForm>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { AccountCreateProvider } from './model'
import { Openai, Xai } from '@boxicons/vue'
import { LayoutGrid } from '@lucide/vue'
import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers'
@@ -6,6 +7,7 @@ import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers'
withDefaults(
defineProps<{
disabled?: boolean
selected?: AccountCreateProvider | ''
}>(),
{
disabled: false,
@@ -36,22 +38,19 @@ const providers = [
</script>
<template>
<div class="flex items-center justify-center gap-4 sm:gap-8" role="group" aria-label="选择账号平台">
<div class="grid grid-cols-3 gap-3" role="group" aria-label="选择账号平台">
<button
v-for="provider in providers"
:key="provider.value"
type="button"
class="group inline-flex size-[88px] cursor-pointer items-center justify-center rounded-cp-lg border-0 bg-transparent text-cp-text outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-border disabled:cursor-not-allowed disabled:opacity-55"
class="flex min-w-0 cursor-pointer flex-col items-center gap-3 rounded-cp border-0 px-2 py-4 text-cp font-medium outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-cp-control-outline disabled:cursor-not-allowed disabled:opacity-55 motion-reduce:transition-none"
:class="selected === provider.value ? 'bg-cp-primary-container text-cp-primary-on-container' : 'bg-cp-fill-quaternary text-cp-text-secondary hover:bg-cp-fill-tertiary hover:text-cp-text'"
:disabled="disabled"
:aria-label="`导入 ${provider.label} 账号`"
:title="provider.label"
:aria-pressed="selected === provider.value"
@click="emit('select', provider.value)"
>
<span
class="inline-flex size-16 items-center justify-center rounded-cp-lg bg-cp-fill-quaternary transition-colors duration-150 group-hover:bg-cp-fill-tertiary"
>
<component :is="provider.icon" :width="36" :height="36" />
</span>
<component :is="provider.icon" :width="24" :height="24" aria-hidden="true" />
<span>{{ provider.label }}</span>
</button>
</div>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { AccountCreateForm } from './model'
import type { AccountGroup } from '@/api'
import AccountSettingsFields from '../AccountSettingsFields.vue'
import AccountProviderChooser from './AccountProviderChooser.vue'
defineProps<{
groups: AccountGroup[]
groupsLoading: boolean
disabled: boolean
proxyError?: string
}>()
const form = defineModel<AccountCreateForm>({ required: true })
</script>
<template>
<div class="grid gap-6">
<fieldset class="m-0 min-w-0 border-0 p-0">
<legend class="mb-3 p-0 text-cp font-medium text-cp-text-secondary">
账号平台
</legend>
<AccountProviderChooser :selected="form.provider" :disabled="disabled" @select="form.provider = $event" />
</fieldset>
<AccountSettingsFields
v-model:enabled="form.enabled"
v-model:concurrency-limit="form.concurrencyLimit"
v-model:weight="form.weight"
v-model:selected-group-ids="form.groupIds"
v-model:proxy-mode="form.proxyMode"
v-model:proxy-url="form.proxyUrl"
:groups="groups"
:groups-loading="groupsLoading"
:preserve-proxy="false"
:disabled="disabled"
:proxy-error="proxyError"
/>
</div>
</template>
@@ -1,124 +1,60 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import type { AccountCreateForm } from './model'
import type { AccountCreateProvider } from './presenter'
import type { AccountCreateForm, AccountImportMode } from './model'
import type { AccountGroup } from '@/api'
import { Openai, Xai } from '@boxicons/vue'
import { Copy, KeyRound, LayoutGrid, Upload } from '@lucide/vue'
import { useFileDialog } from '@vueuse/core'
import { computed, ref } from 'vue'
import { LayoutGrid, Settings2 } from '@lucide/vue'
import { computed } 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 BaseModal from '@/components/base/BaseModal/index.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
import { useCopyText } from '@/composables/useCopyText'
import AccountProxyField from '../AccountProxyField.vue'
import AccountProviderChooser from './AccountProviderChooser.vue'
import { parseAccountSchedulingForm } from '../../utils/schedulingForm'
import AccountImportFields from './AccountImportFields.vue'
import AccountOAuthFields from './AccountOAuthFields.vue'
import AccountSetupFields from './AccountSetupFields.vue'
import { accountProxyError } from './model'
import { resolveAccountCreatePresentation } from './presenter'
const props = withDefaults(
defineProps<{
saving?: boolean
oauthLoading?: boolean
reauthorizing?: boolean
account?: AccountRow | null
}>(),
{
saving: false,
oauthLoading: false,
reauthorizing: false,
account: null,
},
)
const props = withDefaults(defineProps<{
groups: AccountGroup[]
groupsLoading: boolean
saving?: boolean
oauthLoading?: boolean
reauthorizing?: boolean
account?: AccountRow | null
}>(), { saving: false, oauthLoading: false, reauthorizing: false, account: null })
const emit = defineEmits<{
create: []
generateOauth: []
}>()
const emit = defineEmits<{ create: [], generateOauth: [] }>()
const open = defineModel<boolean>({ default: false })
const form = defineModel<AccountCreateForm>('form', { required: true })
const copyWithToast = useCopyText()
const fileError = ref('')
const { open: openImportFile, onChange: onImportFileChange } = useFileDialog({
accept: 'application/json,.json',
multiple: false,
reset: true,
})
const view = computed(() =>
resolveAccountCreatePresentation({
form: form.value,
account: props.account,
saving: props.saving,
oauthLoading: props.oauthLoading,
reauthorizing: props.reauthorizing,
}),
)
const provider = computed({
get: () => form.value.provider,
set: (value: string) => {
if (props.reauthorizing)
return
form.value = { ...form.value, provider: value }
fileError.value = ''
},
})
const busy = computed(() => props.saving || props.oauthLoading)
const proxyError = computed(() => accountProxyError(form.value))
const scheduling = computed(() => parseAccountSchedulingForm(form.value.concurrencyLimit, form.value.weight))
const view = computed(() => resolveAccountCreatePresentation({
form: form.value,
account: props.account,
saving: props.saving,
oauthLoading: props.oauthLoading,
reauthorizing: props.reauthorizing,
}))
const mode = computed({
get: () => form.value.mode,
set: (value: string) => {
if (props.reauthorizing && value !== 'oauth')
return
form.value = { ...form.value, mode: value }
fileError.value = ''
if (!props.reauthorizing && view.value.modeOptions.some(option => option.value === value))
form.value.mode = value as AccountImportMode
},
})
const importText = computed({
get: () => form.value.importText,
get: () => form.value.mode === 'oauth' ? '' : form.value.importTexts[form.value.mode],
set: (value: string) => {
form.value = { ...form.value, importText: value }
fileError.value = ''
if (form.value.mode !== 'oauth')
form.value.importTexts[form.value.mode] = value
},
})
const oauthCallback = computed({
get: () => form.value.oauthCallback,
set: (value: string) => {
form.value = { ...form.value, oauthCallback: value }
},
})
function selectProvider(value: AccountCreateProvider) {
provider.value = value
}
async function loadImportFile(files: FileList | null) {
fileError.value = ''
const file = files?.[0]
if (!file)
return
try {
importText.value = await file.text()
}
catch {
fileError.value = '文件读取失败'
}
}
onImportFileChange((files) => {
void loadImportFile(files)
})
async function copyText(value: string, successText: string) {
await copyWithToast(value, { successText })
function continueToImport() {
if (form.value.provider && scheduling.value.valid && !props.groupsLoading && !proxyError.value && !busy.value)
form.value.step = 'import'
}
</script>
@@ -129,120 +65,71 @@ async function copyText(value: string, successText: string) {
:description="view.modal.description"
:tone="view.modal.tone"
:size="view.modal.size"
:dismissible="!saving"
:dismissible="!busy"
>
<template #icon>
<LayoutGrid v-if="view.isBatch" class="text-cp-text" :width="20" :height="20" />
<Xai v-else-if="view.isXai" class="text-cp-text" :width="20" :height="20" />
<Openai v-else class="text-cp-text" :width="20" :height="20" />
<Settings2 v-if="view.configuring" class="text-cp-text" :size="20" aria-hidden="true" />
<LayoutGrid v-else-if="view.isBatch" class="text-cp-text" :size="20" aria-hidden="true" />
<Xai v-else-if="view.isXai" class="text-cp-text" :width="20" :height="20" aria-hidden="true" />
<Openai v-else class="text-cp-text" :width="20" :height="20" aria-hidden="true" />
</template>
<AccountProviderChooser v-if="view.choosingProvider" :disabled="saving || oauthLoading" @select="selectProvider" />
<div v-else class="flex flex-col gap-4">
<BaseSegmented
v-if="!reauthorizing && !view.isBatch"
v-model="mode"
label="账号添加方式"
:options="view.modeOptions"
class="w-full"
<div class="grid gap-4">
<AccountSetupFields
v-if="view.configuring"
v-model="form"
:disabled="busy"
:groups="groups"
:groups-loading="groupsLoading"
:proxy-error="form.proxyUrl.trim() ? proxyError : undefined"
/>
<div v-if="mode === 'oauth'" class="flex flex-col gap-4">
<AccountProxyField v-if="!reauthorizing" v-model:mode="form.proxyMode" v-model:url="form.proxyUrl" :preserve="false" :disabled="saving || oauthLoading || Boolean(form.oauthFlowId)" />
<div class="rounded-cp bg-cp-fill-quaternary px-4 py-3">
<div class="flex items-start gap-3">
<div
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-cp bg-cp-bg-container text-cp-primary-text"
>
<KeyRound class="size-4" />
</div>
<div class="min-w-0 flex-1">
<p class="m-0 text-cp font-bold text-cp-text">
{{ view.oauth.panelTitle }}
</p>
<p class="m-0 mt-1 text-cp-sm leading-[1.55] font-medium text-cp-text-secondary">
{{ view.oauth.panelDescription }}
</p>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<BaseButton
variant="secondary"
:loading="oauthLoading"
:disabled="!view.canGenerateOauth"
@click="emit('generateOauth')"
>
{{ reauthorizing ? '重新生成授权链接' : '生成授权链接' }}
</BaseButton>
</div>
<BaseForm v-if="view.oauth.authUrl">
<BaseFormItem label="授权链接">
<template #extra>
<BaseIconButton
variant="secondary"
size="sm"
title="复制链接"
label="复制链接"
:disabled="saving || oauthLoading"
@click="copyText(view.oauth.authUrl, '授权链接已复制')"
>
<Copy class="size-3.5" />
</BaseIconButton>
</template>
<BaseScrollbar max-height="92px">
<div class="rounded-cp bg-[var(--cp-input-bg)] px-3.5 py-3 shadow-cp-tertiary">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-sm leading-[1.6] font-emphasis text-cp-text-secondary"
v-text="view.oauth.authUrl"
/>
</div>
</BaseScrollbar>
</BaseFormItem>
</BaseForm>
<BaseForm>
<BaseFormItem :label="view.oauth.callbackLabel" required>
<BaseTextarea
v-model="oauthCallback"
:aria-label="view.oauth.callbackLabel"
:rows="4"
:placeholder="view.oauth.callbackPlaceholder"
:disabled="saving"
/>
</BaseFormItem>
</BaseForm>
</div>
<BaseForm v-else>
<BaseFormItem :label="view.importInput.label" required :error="fileError || undefined">
<template v-if="view.importInput.uploadable" #extra>
<BaseButton variant="secondary" size="sm" :disabled="saving" @click="openImportFile()">
<template #icon>
<Upload class="size-3.5" />
</template>
上传文件
</BaseButton>
</template>
<BaseTextarea
v-model="importText"
:aria-label="view.importInput.label"
:rows="9"
:placeholder="view.importInput.placeholder"
:disabled="saving"
/>
</BaseFormItem>
</BaseForm>
<p v-if="view.configuring && !scheduling.valid" class="m-0 text-xs text-cp-error" role="alert">
{{ scheduling.message }}
</p>
<template v-if="!view.configuring">
<BaseSegmented
v-if="!reauthorizing && !view.isBatch"
v-model="mode"
label="账号添加方式"
:options="view.modeOptions"
:disabled="busy"
class="w-full"
/>
<AccountOAuthFields
v-if="mode === 'oauth'"
v-model="form.oauthCallback"
:auth-url="view.oauth.authUrl"
:panel-title="`${view.isXai ? 'xAI' : 'OpenAI'} OAuth ${reauthorizing ? '重新授权' : '授权'}`"
:panel-description="view.isXai ? '生成并打开授权链接、完成浏览器授权、粘贴回调地址或授权码' : '生成并打开授权链接、完成浏览器授权、粘贴回调地址'"
:loading="oauthLoading"
:callback-label="view.oauth.callbackLabel"
:callback-placeholder="view.oauth.callbackPlaceholder"
:disabled="busy"
@regenerate="emit('generateOauth')"
/>
<AccountImportFields
v-else
:key="mode"
v-model="importText"
:label="view.importInput.label"
:placeholder="view.importInput.placeholder"
:uploadable="view.importInput.uploadable"
:disabled="busy"
/>
</template>
</div>
<template v-if="!view.choosingProvider" #footer>
<BaseButton variant="ghost" :disabled="saving" @click="open = false">
<template #footer>
<BaseButton v-if="view.configuring || reauthorizing" variant="secondary" :disabled="busy" @click="open = false">
取消
</BaseButton>
<BaseButton variant="primary" :loading="saving" :disabled="!view.canSubmit" @click="emit('create')">
<BaseButton v-else class="mr-auto" variant="secondary" :disabled="busy" @click="form.step = 'settings'">
上一步
</BaseButton>
<BaseButton v-if="view.configuring" variant="primary" :disabled="!form.provider || !scheduling.valid || groupsLoading || Boolean(proxyError) || busy" @click="continueToImport">
继续导入
</BaseButton>
<BaseButton v-else variant="primary" :loading="saving || oauthLoading" :disabled="!view.canSubmit" @click="emit('create')">
{{ view.submitLabel }}
</BaseButton>
</template>
@@ -1,8 +1,18 @@
import { parseAccountSchedulingForm } from '../../utils/schedulingForm'
export type AccountCreateProvider = 'batch' | 'openai' | 'xai'
export type AccountImportMode = 'oauth' | 'access_token' | 'refresh_token' | 'json'
export type AccountImportInputMode = Exclude<AccountImportMode, 'oauth'>
export interface AccountCreateForm {
provider: string
name: string
mode: string
importText: string
provider: AccountCreateProvider | ''
enabled: boolean
concurrencyLimit: string
weight: string
groupIds: string[]
step: 'settings' | 'import'
mode: AccountImportMode
importTexts: Record<AccountImportInputMode, string>
oauthFlowId: string
oauthAuthUrl: string
oauthCallback: string
@@ -13,9 +23,13 @@ export interface AccountCreateForm {
export function emptyAccountCreateForm(): AccountCreateForm {
return {
provider: '',
name: '',
enabled: true,
concurrencyLimit: '',
weight: '1',
groupIds: [],
step: 'settings',
mode: 'oauth',
importText: '',
importTexts: { access_token: '', refresh_token: '', json: '' },
oauthFlowId: '',
oauthAuthUrl: '',
oauthCallback: '',
@@ -23,3 +37,28 @@ export function emptyAccountCreateForm(): AccountCreateForm {
proxyUrl: '',
}
}
export function accountProxyError(form: AccountCreateForm): string | undefined {
if (form.proxyMode !== 'proxy')
return undefined
if (!form.proxyUrl.trim())
return '请输入代理 URL'
try {
const url = new URL(form.proxyUrl.trim())
if (!['http:', 'https:', 'socks5:', 'socks5h:'].includes(url.protocol) || !url.hostname)
return '请输入 HTTP、HTTPS 或 SOCKS5 代理地址'
if (url.protocol.startsWith('socks5') && !url.port)
return 'SOCKS5 代理地址需要填写端口'
}
catch {
return '请输入有效的代理 URL'
}
return undefined
}
export function accountImportSettings(form: AccountCreateForm) {
const scheduling = parseAccountSchedulingForm(form.concurrencyLimit, form.weight)
if (!scheduling.valid)
throw new Error(scheduling.message)
return { enabled: form.enabled, ...scheduling.values, groupIds: [...new Set(form.groupIds)] }
}
@@ -1,7 +1,5 @@
import type { AccountRow } from '../../constants'
import type { AccountCreateForm } from './model'
export type AccountCreateProvider = 'batch' | 'openai' | 'xai'
import type { AccountCreateForm, AccountCreateProvider } from './model'
interface AccountCreatePresentationInput {
form: AccountCreateForm
@@ -27,19 +25,17 @@ const modeOptions = {
export function resolveAccountCreatePresentation(input: AccountCreatePresentationInput) {
const { form } = input
const provider = isAccountCreateProvider(form.provider) ? form.provider : undefined
const providerSelected = provider !== undefined
const choosingProvider = !input.reauthorizing && !providerSelected
const configuring = !input.reauthorizing && form.step === 'settings'
const oauthAuthUrl = form.oauthAuthUrl || ''
return {
choosingProvider,
configuring,
isXai: provider === 'xai',
isBatch: provider === 'batch',
modeOptions: resolveModeOptions(provider),
modal: resolveModal(input, provider, choosingProvider),
oauth: resolveOAuth(input, provider, oauthAuthUrl),
modal: resolveModal(input, provider, configuring),
oauth: resolveOAuth(provider, oauthAuthUrl),
importInput: resolveImportInput(form, provider),
canGenerateOauth: providerSelected && !input.saving && !input.oauthLoading,
canSubmit: canSubmit(input, provider, oauthAuthUrl),
submitLabel: resolveSubmitLabel(input, provider),
}
@@ -54,14 +50,14 @@ function resolveModeOptions(provider: AccountCreateProvider | undefined) {
function resolveModal(
input: AccountCreatePresentationInput,
provider: AccountCreateProvider | undefined,
choosingProvider: boolean,
configuring: boolean,
) {
if (choosingProvider) {
if (configuring) {
return {
title: '选择账号平台',
description: undefined,
title: '账号设置',
description: '设置将应用于本次导入的账号',
tone: 'neutral' as const,
size: 'sm' as const,
size: 'md' as const,
}
}
@@ -69,7 +65,7 @@ function resolveModal(
const providerName = provider === 'xai' ? 'xAI' : 'OpenAI'
return {
title: '重新授权账号',
description: `完成新的 ${providerName} 授权并替换此账号凭据`,
description: `${input.account?.email || input.account?.name || providerName} · 完成授权后更新账号凭据`,
tone: 'info' as const,
size: 'md' as const,
}
@@ -97,24 +93,10 @@ function resolveModal(
}
}
function resolveOAuth(
input: AccountCreatePresentationInput,
provider: AccountCreateProvider | undefined,
authUrl: string,
) {
let panelTitle = provider === 'xai' ? 'xAI OAuth 授权' : 'OpenAI OAuth 授权'
if (input.reauthorizing) {
panelTitle = input.account?.email
|| input.account?.accountId
|| input.account?.id
|| '该账号'
}
function resolveOAuth(provider: AccountCreateProvider | undefined, authUrl: string) {
if (provider === 'xai') {
return {
authUrl,
panelTitle,
panelDescription: '生成并打开授权链接 、 完成浏览器授权 、 粘贴回调地址,查询字符串或授权码',
callbackLabel: '回调地址或授权码',
callbackPlaceholder: '回调地址、?code=...&state=... 或授权码',
}
@@ -122,8 +104,6 @@ function resolveOAuth(
return {
authUrl,
panelTitle,
panelDescription: '生成并打开授权链接 、 完成浏览器授权 、 粘贴回调地址',
callbackLabel: '回调地址',
callbackPlaceholder: 'http://localhost:1455/auth/callback?code=...&state=...',
}
@@ -175,10 +155,10 @@ function resolveSubmitLabel(
if (input.reauthorizing)
return '完成重新授权'
if (input.form.mode === 'oauth')
return '完成授权导入'
return '完成导入'
if (provider === 'batch')
return '批量导入'
return '导入'
return '导入账号'
}
function canSubmit(
@@ -189,7 +169,7 @@ function canSubmit(
if (!provider || input.saving || input.oauthLoading)
return false
if (input.form.mode !== 'oauth')
return input.form.importText.trim().length > 0
return input.form.importTexts[input.form.mode].trim().length > 0
return Boolean(
input.form.oauthFlowId
&& oauthAuthUrl
@@ -2,16 +2,12 @@
import type { AccountRow } from '../constants'
import type { AccountGroup } from '@/api'
import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseSwitch from '@/components/base/BaseSwitch.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountIdentityCell from './AccountIdentityCell.vue'
import AccountPlanBadge from './AccountPlanBadge.vue'
import AccountProxyField from './AccountProxyField.vue'
import AccountSettingsFields from './AccountSettingsFields.vue'
defineProps<{
account: AccountRow | null
@@ -59,53 +55,22 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
</div>
</div>
<div class="flex min-h-6 items-center justify-between gap-3">
<span class="text-cp leading-none font-medium text-cp-text-secondary">调度</span>
<BaseSwitch
v-model="enabled"
label="切换账号调度"
:disabled="saving"
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="并发限制">
<BaseInput
v-model="concurrencyLimit"
aria-label="账号并发限制"
type="number"
min="1"
max="4294967295"
placeholder="留空使用默认值"
:disabled="saving"
/>
</BaseFormItem>
<BaseFormItem label="权重">
<BaseInput
v-model="weight"
aria-label="所选账号调度权重"
type="number"
min="1"
max="100"
placeholder="越高越优先,最大 100"
:disabled="saving"
/>
</BaseFormItem>
</div>
<BaseFormItem label="所属分组">
<AccountGroupCheckboxGrid
v-model="selectedGroupIds"
:groups="groups"
:loading="groupsLoading"
:disabled="saving"
/>
</BaseFormItem>
<AccountProxyField v-model:mode="proxyMode" v-model:url="proxyUrl" :endpoint="account.outboundProxyEndpoint" :disabled="saving" />
<AccountSettingsFields
v-model:enabled="enabled"
v-model:concurrency-limit="concurrencyLimit"
v-model:weight="weight"
v-model:selected-group-ids="selectedGroupIds"
v-model:proxy-mode="proxyMode"
v-model:proxy-url="proxyUrl"
:groups="groups"
:groups-loading="groupsLoading"
:disabled="saving"
:endpoint="account.outboundProxyEndpoint"
/>
</div>
<template #footer>
<BaseButton variant="ghost" :disabled="saving" @click="open = false">
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton
@@ -64,7 +64,7 @@ const { profile, loading, error, load } = useAccountProfileStatistics(accountId,
<span v-if="error && profile" class="mr-auto self-center text-cp-xs font-semibold text-cp-error-text">
{{ error }}
</span>
<BaseButton variant="ghost" @click="open = false">
<BaseButton variant="secondary" @click="open = false">
关闭
</BaseButton>
<BaseButton :loading="loading" @click="load(true)">
@@ -9,6 +9,7 @@ const props = withDefaults(
endpoint?: string | null
disabled?: boolean
preserve?: boolean
error?: string
}>(),
{ preserve: true },
)
@@ -35,7 +36,7 @@ const options = computed(() => [
{{ endpoint }}
</p>
</BaseFormItem>
<BaseFormItem v-if="mode === 'proxy'" label="代理 URL" required>
<BaseFormItem v-if="mode === 'proxy'" label="代理 URL" required :error="error">
<BaseInput
v-model="url"
type="password"
@@ -239,7 +239,7 @@ function handleRequestConsume(creditId: string) {
</div>
<template v-if="showConfirm" #footer>
<BaseButton variant="ghost" :disabled="consuming" @click="cancelConsume">
<BaseButton variant="secondary" :disabled="consuming" @click="cancelConsume">
返回
</BaseButton>
<BaseButton variant="primary" :loading="consuming" @click="confirmConsume">
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { AccountGroup } from '@/api'
import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseSwitch from '@/components/base/BaseSwitch.vue'
import AccountProxyField from './AccountProxyField.vue'
withDefaults(defineProps<{
groups: AccountGroup[]
groupsLoading: boolean
disabled: boolean
endpoint?: string | null
preserveProxy?: boolean
proxyError?: string
}>(), { preserveProxy: true })
const enabled = defineModel<boolean>('enabled', { required: true })
const concurrencyLimit = defineModel<string>('concurrencyLimit', { required: true })
const weight = defineModel<string>('weight', { required: true })
const proxyMode = defineModel<string>('proxyMode', { required: true })
const proxyUrl = defineModel<string>('proxyUrl', { required: true })
const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: true })
</script>
<template>
<div class="grid gap-5">
<div class="flex min-h-6 items-center justify-between gap-3">
<span class="text-cp leading-none font-medium text-cp-text-secondary">调度</span>
<BaseSwitch
v-model="enabled"
label="切换账号调度"
:disabled="disabled"
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="并发限制">
<BaseInput
v-model="concurrencyLimit"
aria-label="账号并发限制"
type="number"
min="1"
max="4294967295"
placeholder="留空使用默认值"
:disabled="disabled"
/>
</BaseFormItem>
<BaseFormItem label="权重">
<BaseInput
v-model="weight"
aria-label="账号调度权重"
type="number"
min="1"
max="100"
placeholder="越高越优先,最大 100"
:disabled="disabled"
/>
</BaseFormItem>
</div>
<BaseFormItem label="所属分组">
<AccountGroupCheckboxGrid
v-model="selectedGroupIds"
:groups="groups"
:loading="groupsLoading"
:disabled="disabled"
/>
</BaseFormItem>
<AccountProxyField v-model:mode="proxyMode" v-model:url="proxyUrl" :preserve="preserveProxy" :error="proxyError" :endpoint="endpoint" :disabled="disabled" />
</div>
</template>
@@ -11,7 +11,7 @@ import { useAsyncAction } from '@/composables/useAsyncAction'
import { errorMessage } from '@/utils/async'
import { isRecord } from '@/utils/object'
import { formatProviderLabel, isSupportedProvider } from '@/utils/providers'
import { emptyAccountCreateForm } from '../components/AccountCreateModal/model'
import { accountImportSettings, accountProxyError, emptyAccountCreateForm } from '../components/AccountCreateModal/model'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
type ImportProvider = 'openai' | 'xai'
@@ -57,6 +57,9 @@ export function useAccountOnboarding(options: {
await creatingAccountAction.run(
async () => {
const proxyError = accountProxyError(createForm.value)
if (proxyError)
throw new Error(proxyError)
const message = createForm.value.provider === 'batch'
? await importMixedAccountDocument()
: await importAccountDocument()
@@ -72,10 +75,11 @@ export function useAccountOnboarding(options: {
await authorizingOAuthAction.run(
async () => {
const input = await newAccountInput()
const input = newAccountInput()
const account = reauthorizingAccount.value
if (!account && createForm.value.proxyMode === 'proxy' && !createForm.value.proxyUrl.trim())
throw new Error('请输入代理 URL')
const proxyError = accountProxyError(createForm.value)
if (!account && proxyError)
throw new Error(proxyError)
const result = await startAccountOAuth({
...input,
outboundProxyUrl: !account && createForm.value.proxyMode === 'proxy' ? createForm.value.proxyUrl.trim() : undefined,
@@ -117,6 +121,7 @@ export function useAccountOnboarding(options: {
provider: createForm.value.provider,
flowId: createForm.value.oauthFlowId,
callbackUrl,
settings: reauthorizingAccount.value ? undefined : accountImportSettings(createForm.value),
})
await finishCreate(
reauthorizingAccount.value
@@ -145,14 +150,14 @@ export function useAccountOnboarding(options: {
createForm.value = {
...emptyAccountCreateForm(),
provider: account.provider,
name: account.name,
step: 'import',
mode: 'oauth',
}
showCreateModal.value = true
void handleAuthorizeOAuth()
}
async function newAccountInput() {
function newAccountInput() {
const account = reauthorizingAccount.value
return {
provider: createForm.value.provider,
@@ -162,16 +167,22 @@ export function useAccountOnboarding(options: {
async function importAccountDocument() {
const provider = requireImportProvider(createForm.value.provider)
const mode = createForm.value.mode
if (mode === 'oauth')
throw new Error('请选择凭据导入方式')
const documents = accountImportDocuments(
provider,
createForm.value.mode,
createForm.value.importText,
mode,
createForm.value.importTexts[mode],
)
let importedCount = 0
for (const entry of documents) {
const result = await importAccounts({
provider,
data: entry.document,
settings: accountImportSettings(createForm.value),
data: createForm.value.proxyMode === 'proxy'
? withDefaultImportProxy(entry.document, createForm.value.proxyUrl.trim())
: entry.document,
})
importedCount += result.importedCount
}
@@ -179,7 +190,7 @@ export function useAccountOnboarding(options: {
}
async function importMixedAccountDocument() {
const documents = parseMixedImportDocuments(parseImportJson(createForm.value.importText))
const documents = parseMixedImportDocuments(parseImportJson(createForm.value.importTexts.json))
let importedCount = 0
const failures: string[] = []
@@ -187,7 +198,10 @@ export function useAccountOnboarding(options: {
try {
const result = await importAccounts({
provider: entry.provider,
data: entry.document,
settings: accountImportSettings(createForm.value),
data: createForm.value.proxyMode === 'proxy'
? withDefaultImportProxy(entry.document, createForm.value.proxyUrl.trim())
: entry.document,
})
importedCount += result.importedCount
}
@@ -219,11 +233,26 @@ export function useAccountOnboarding(options: {
createForm.value = {
...createForm.value,
mode: createForm.value.provider === 'batch' ? 'json' : 'oauth',
importTexts: { access_token: '', refresh_token: '', json: '' },
oauthFlowId: '',
oauthAuthUrl: '',
oauthCallback: '',
}
},
{ flush: 'sync' },
)
watch(
[
() => createForm.value.proxyMode,
() => createForm.value.proxyMode === 'proxy' ? createForm.value.proxyUrl.trim() : '',
],
() => {
createForm.value.oauthFlowId = ''
createForm.value.oauthAuthUrl = ''
createForm.value.oauthCallback = ''
},
{ flush: 'sync' },
)
return {
@@ -239,6 +268,20 @@ export function useAccountOnboarding(options: {
}
}
function withDefaultImportProxy(document: Record<string, unknown>, proxyUrl: string): Record<string, unknown> {
if (isRecord(document.data) && Array.isArray(document.data.accounts))
return { ...document, data: withDefaultImportProxy(document.data, proxyUrl) }
if (Array.isArray(document.accounts)) {
return {
...document,
accounts: document.accounts.map(account => isRecord(account) ? withDefaultImportProxy(account, proxyUrl) : account),
}
}
if (['outboundProxyUrl', 'outbound_proxy_url', 'proxy_key'].some(key => Object.hasOwn(document, key)))
return document
return { ...document, outboundProxyUrl: proxyUrl }
}
function parseImportJson(value: string) {
try {
return JSON.parse(value)
+3 -1
View File
@@ -87,7 +87,7 @@ const {
} = useAccountMutations({
accounts,
selectedIds,
reload: loadAccounts,
reload: () => Promise.all([loadAccounts(), loadGroups()]),
replaceAccount,
})
@@ -335,6 +335,8 @@ const {
v-model="showCreateModal"
v-model:form="createForm"
:account="reauthorizingAccount"
:groups="groups"
:groups-loading="groupsLoading"
:oauth-loading="authorizingOAuth"
:reauthorizing="Boolean(reauthorizingAccount)"
:saving="creatingAccount"
@@ -130,7 +130,7 @@ const title = computed(() => props.editing ? '编辑 API Key' : '创建 API Key'
</BaseForm>
<template #footer>
<BaseButton variant="ghost" :disabled="saving" @click="open = false">
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton
@@ -113,7 +113,7 @@ watch(requestId, () => {
暂无待核账请求
</p>
<template #footer>
<BaseButton variant="ghost" :disabled="saving" @click="open = false">
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton variant="primary" :loading="saving" :disabled="!valid || !requestId || loading || Boolean(error)" @click="save">