feat(accounts): add notes and refine account interactions (#100)

Persist optional account notes across creation, editing, imports, and credential refreshes. Show notes in compact account rows with overflow-only popovers.

Support vertically resizable textareas, bounded select menus with custom scrollbars, and quota updates without table loading.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-16 01:36:34 +08:00
committed by GitHub
co-authored by Zyy Codex
parent 90deabdfe3
commit 0a09c1572f
31 changed files with 655 additions and 124 deletions
@@ -18,9 +18,10 @@ pub use gateway_core::account::{
QuotaState, resolve_account_status,
};
/// 导入时统一应用的账号调度与分组设置;缺省时保留原有导入语义。
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountImportSettings {
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
@@ -86,6 +87,7 @@ pub struct AccountRecord {
pub provider_kind: ProviderKind,
pub groups: Vec<AccountGroupRef>,
pub name: String,
pub notes: Option<String>,
pub email: Option<String>,
pub upstream_user_id: Option<String>,
pub upstream_account_id: Option<String>,
@@ -217,6 +219,8 @@ pub struct AccountSummary {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccount {
pub account_id: String,
/// 缺省保留备注;空字符串清空备注。
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
@@ -1330,6 +1330,7 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
.update(
&context("update-request"),
UpdateAccount {
notes: None,
outbound_proxy: None,
account_id: "acct_test".to_owned(),
enabled: false,
@@ -1367,6 +1368,7 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
.update(
&context("update-failure"),
UpdateAccount {
notes: None,
outbound_proxy: None,
account_id: "acct_test".to_owned(),
enabled: false,
@@ -2400,6 +2402,7 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
pub(super) fn account_record(kind: &str) -> AccountRecord {
let now = Utc::now();
AccountRecord {
notes: None,
outbound_proxy: None,
id: "acct_test".to_owned(),
provider_kind: ProviderKind::new(kind).expect("provider kind"),
@@ -2733,6 +2736,7 @@ fn unsupported() -> ProviderAdminError {
pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings {
gateway_admin::model::accounts::AccountImportSettings {
notes: Some("团队备用".to_owned()),
enabled: false,
concurrency_limit: Some(
gateway_core::account::AccountConcurrencyLimit::new(3).expect("concurrency"),
@@ -2,6 +2,18 @@
use super::*;
fn validate_account_notes(notes: Option<&str>) -> Result<(), WireValidationError> {
if notes.is_some_and(|notes| {
notes.chars().count() > 500
|| notes
.chars()
.any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))
}) {
return Err(WireValidationError::new("notes"));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum AccountProvider {
OpenAi,
@@ -18,10 +30,11 @@ impl AccountProvider {
}
}
/// 导入统一设置,复用编辑账号的调度和分组约束。
/// 导入统一设置,复用编辑账号的备注、调度和分组约束。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportSettingsRequest {
pub notes: Option<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
@@ -31,6 +44,7 @@ pub struct AccountImportSettingsRequest {
impl AccountImportSettingsRequest {
fn validate(&self) -> Result<(), WireValidationError> {
validate_account_notes(self.notes.as_deref())?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
validate_wire_group_ids(&self.group_ids)?;
@@ -41,6 +55,7 @@ impl AccountImportSettingsRequest {
self,
) -> Result<gateway_admin::model::accounts::AccountImportSettings, WireValidationError> {
Ok(gateway_admin::model::accounts::AccountImportSettings {
notes: self.notes,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
@@ -200,6 +215,7 @@ pub struct UpdateAccountRequest {
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
pub account_id: String,
pub notes: Option<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
@@ -210,6 +226,7 @@ pub struct UpdateAccountRequest {
impl UpdateAccountRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
validate_account_notes(self.notes.as_deref())?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
validate_wire_group_ids(&self.group_ids)?;
@@ -224,6 +241,7 @@ impl UpdateAccountRequest {
self.outbound_proxy_url,
)?,
account_id: self.account_id,
notes: self.notes,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
@@ -75,6 +75,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
AccountView {
id: account.id.clone(),
name: account.name,
notes: account.notes,
provider: account.provider_kind.to_string(),
groups: account
.groups
@@ -240,6 +240,7 @@ pub struct AccountView {
pub outbound_proxy_endpoint: Option<String>,
pub id: String,
pub name: String,
pub notes: Option<String>,
pub provider: String,
pub groups: Vec<AccountGroupRefView>,
pub resource_ref: String,
@@ -407,6 +407,43 @@ mod batch_update {
}
}
#[test]
fn single_update_should_accept_optional_unicode_and_multiline_notes() {
use gateway_api::admin::accounts::UpdateAccountRequest;
use serde_json::json;
for notes in [
json!(null),
json!(""),
json!("团队备用\n第二行\t说明"),
json!("备".repeat(500)),
] {
let request: UpdateAccountRequest = serde_json::from_value(json!({
"accountId": "acct_notes", "enabled": true, "concurrencyLimit": null,
"weight": 1, "groupIds": [], "notes": notes
}))
.unwrap();
request.validate().expect("accept bounded notes");
}
}
#[test]
fn single_update_should_reject_oversized_notes_and_control_characters() {
use gateway_api::admin::accounts::UpdateAccountRequest;
use serde_json::json;
for notes in [
"备".repeat(501),
"备注\0".to_owned(),
"备注\u{001b}".to_owned(),
] {
let request: UpdateAccountRequest = serde_json::from_value(json!({
"accountId": "acct_notes", "enabled": true, "concurrencyLimit": null,
"weight": 1, "groupIds": [], "notes": notes
}))
.unwrap();
assert_eq!(request.validate().unwrap_err().field(), "notes");
}
}
mod response {
use gateway_api::admin::accounts::AccountUsageView;
use gateway_api::admin::presenter::format_decimal_currency;
@@ -845,6 +882,9 @@ mod import_settings {
("weight", json!(0)),
("weight", json!(101)),
("groupIds", json!(["invalid-group"])),
("notes", json!("备".repeat(501))),
("notes", json!("备注\u{0000}")),
("notes", json!("备注\u{001b}")),
] {
let mut settings =
json!({"enabled": false, "concurrencyLimit": null, "weight": 1, "groupIds": []});
@@ -865,6 +905,25 @@ mod import_settings {
}
}
#[test]
fn import_and_oauth_accept_optional_unicode_and_multiline_notes() {
for notes in [
json!(null),
json!(""),
json!("团队备用\n下月续费\t"),
json!("备".repeat(500)),
] {
let settings = json!({"enabled": true, "concurrencyLimit": null, "weight": 1, "groupIds": [], "notes": notes});
let import: AccountImportRequest = serde_json::from_value(
json!({"provider": "openai", "data": {}, "settings": settings}),
)
.unwrap();
let oauth: CompleteAccountAuthorizationRequest = serde_json::from_value(json!({"provider": "xai", "flowId": "flow-test", "callbackUrl": "code", "settings": settings})).unwrap();
import.validate().expect("import notes");
oauth.validate().expect("OAuth notes");
}
}
#[test]
fn import_settings_require_a_complete_explicit_configuration() {
let settings =
@@ -473,6 +473,7 @@ mod provider {
};
AccountPageItem {
account: AccountRecord {
notes: None,
id: "acct_error_test".to_owned(),
provider_kind: ProviderKind::new(provider).unwrap(),
groups: Vec::new(),
@@ -165,7 +165,7 @@ impl AccountGroupStore for PgAccountGroupRepository {
.collect::<Vec<_>>();
let rows = sqlx::query(
"select membership.account_group_id,
account.id, account.provider_kind, account.name, account.email,
account.id, account.provider_kind, account.name, account.notes, account.email,
account.upstream_user_id, account.upstream_account_id, account.plan_type,
account.authentication_kind, account.credential_revision, account.outbound_proxy_url,
account.has_refresh_token, account.access_token_expires_at,
@@ -226,9 +226,12 @@ impl PgAdminAccountStore {
.collect::<StoreResult<Vec<_>>>()
.map_err(|error| admin_store_error(ENTITY, error))?;
let mut changed_fields = vec!["credentials".to_owned()];
if settings.is_some() {
if let Some(settings) = &settings {
changed_fields
.extend(["enabled", "concurrency_limit", "weight", "group_ids"].map(str::to_owned));
if settings.notes.is_some() {
changed_fields.push("notes".to_owned());
}
}
let imported = self
.accounts
@@ -670,10 +673,14 @@ impl AccountStore for PgAdminAccountStore {
if command.outbound_proxy.is_some() {
changed_fields.push("outbound_proxy".to_owned());
}
if command.notes.is_some() {
changed_fields.push("notes".to_owned());
}
let config_revision = self
.accounts
.batch_update_provider_accounts_admin(BatchUpdateProviderAccountsAdmin {
account_ids: vec![command.account_id.clone()],
notes: command.notes,
enabled: command.enabled,
concurrency_limit: command.concurrency_limit,
weight: command.weight,
@@ -767,6 +774,7 @@ impl AccountStore for PgAdminAccountStore {
.accounts
.batch_update_provider_accounts_admin(BatchUpdateProviderAccountsAdmin {
account_ids: command.account_ids,
notes: None,
enabled: command.enabled,
concurrency_limit: command.concurrency_limit,
weight: command.weight,
@@ -116,7 +116,7 @@ pub(crate) async fn load_admin_account_page(
from runtime_settings
where id = 1
)
select a.outbound_proxy_url, a.id, a.provider_kind, a.name, a.email, a.upstream_user_id,
select a.outbound_proxy_url, a.id, a.provider_kind, a.name, a.notes, a.email, a.upstream_user_id,
a.upstream_account_id, a.plan_type, a.authentication_kind,
a.credential_revision, a.has_refresh_token, a.access_token_expires_at,
a.next_refresh_at, a.enabled, a.concurrency_limit, a.weight,
@@ -17,6 +17,7 @@ pub(crate) fn admin_account_record(
})?,
groups: Vec::new(),
name: summary.name,
notes: summary.notes,
email: summary.email,
upstream_user_id: summary.upstream_user_id,
upstream_account_id: summary.upstream_account_id,
@@ -108,7 +108,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
include_disabled: bool,
) -> StoreResult<Vec<ProviderAccountSummary>> {
let rows = sqlx::query(
"select outbound_proxy_url, id, provider_kind, name, email, upstream_user_id,
"select outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id,
upstream_account_id, plan_type, authentication_kind, credential_revision, has_refresh_token,
access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, credential_state,
credential_observed_at, quota_access_state, quota_evidence,
@@ -504,6 +504,14 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
&settings.group_ids,
)
.await?;
if let Some(notes) = settings.notes.as_deref() {
update_provider_account_notes_in_transaction(
&mut transaction,
&unique_ids,
notes,
)
.await?;
}
}
append_admin_audit_event_in_transaction(&mut transaction, command.audit, revision)
.await?;
@@ -591,6 +599,14 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
&command.group_ids,
)
.await?;
if let Some(notes) = command.notes.as_deref() {
update_provider_account_notes_in_transaction(
&mut transaction,
&command.account_ids,
notes,
)
.await?;
}
append_admin_audit_event_in_transaction(&mut transaction, command.audit, revision)
.await?;
Ok(revision)
@@ -678,6 +694,20 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
}
}
async fn update_provider_account_notes_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
account_ids: &[String],
notes: &str,
) -> StoreResult<()> {
sqlx::query("update provider_accounts set notes = nullif($2, '') where id = any($1::text[])")
.bind(account_ids)
.bind(notes.trim())
.execute(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("update provider account notes"))?;
Ok(())
}
async fn replace_account_group_assignments_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
account_ids: &[String],
@@ -124,6 +124,7 @@ pub struct ProviderAccountSummary {
pub id: String,
pub provider_kind: String,
pub name: String,
pub notes: Option<String>,
pub email: Option<String>,
pub upstream_user_id: Option<String>,
pub upstream_account_id: Option<String>,
@@ -316,6 +317,7 @@ impl fmt::Debug for RotateProviderAccount {
pub struct BatchUpdateProviderAccountsAdmin {
pub outbound_proxy: Option<gateway_admin::model::proxies::AccountProxySelection>,
pub account_ids: Vec<String>,
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
@@ -380,7 +382,7 @@ impl ProviderAccountStateUpdate {
}
}
pub(crate) const ACCOUNT_SELECT: &str = "select outbound_proxy_url, id, provider_kind, name, email, upstream_user_id,
pub(crate) const ACCOUNT_SELECT: &str = "select outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id,
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, credential_state,
provider_quota_json, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at,
@@ -388,7 +390,7 @@ pub(crate) const ACCOUNT_SELECT: &str = "select outbound_proxy_url, id, provider
credential_observed_at, quota_observed_at, created_at, updated_at
from provider_accounts where id = $1";
pub(crate) const ACCOUNT_SELECT_BY_IDS: &str = "select outbound_proxy_url, id, provider_kind, name, email, upstream_user_id,
pub(crate) const ACCOUNT_SELECT_BY_IDS: &str = "select outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id,
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, credential_state,
provider_quota_json, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at,
@@ -398,7 +400,7 @@ pub(crate) const ACCOUNT_SELECT_BY_IDS: &str = "select outbound_proxy_url, id, p
where id = any($1::text[]) and provider_kind = $2
order by id";
pub(crate) const REFRESH_CANDIDATES_SELECT: &str = "select outbound_proxy_url, id, provider_kind, name, email, upstream_user_id,
pub(crate) const REFRESH_CANDIDATES_SELECT: &str = "select outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id,
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, credential_state,
provider_quota_json, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at,
@@ -541,6 +543,7 @@ pub(crate) fn account_summary_from_row(
id: get(&row, "id")?,
provider_kind: get(&row, "provider_kind")?,
name: get(&row, "name")?,
notes: get(&row, "notes")?,
email: get(&row, "email")?,
upstream_user_id: get(&row, "upstream_user_id")?,
upstream_account_id: get(&row, "upstream_account_id")?,
@@ -1048,6 +1048,7 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
let result = store
.update_account(
UpdateAccount {
notes: None,
outbound_proxy: None,
account_id: "acct_terminal_mutation".to_owned(),
enabled: false,
@@ -1127,6 +1128,7 @@ async fn account_proxy_edits_preserve_credentials_and_clear_egress_without_audit
request_id: "proxy-edit".to_owned(),
};
let command = UpdateAccount {
notes: None,
account_id: "acct_proxy".to_owned(),
enabled: true,
concurrency_limit: None,
@@ -1181,6 +1183,208 @@ async fn account_proxy_edits_preserve_credentials_and_clear_egress_without_audit
database.close().await;
}
#[tokio::test]
async fn account_notes_round_trip_and_survive_import_and_scheduling_updates() {
let Some(database) = TestDatabase::create("account_notes").await else {
return;
};
let repository = PgProviderAccountRepository::new(database.pool.clone());
repository
.insert_provider_account(account("acct_notes", "notes-user"))
.await
.unwrap();
let store = admin_account_store(&database.pool);
let context = MutationContext {
actor: MutationActor::System,
request_id: "notes-edit".to_owned(),
};
let command = UpdateAccount {
account_id: "acct_notes".to_owned(),
notes: Some(" 团队备用\n下月续费 ".to_owned()),
enabled: true,
concurrency_limit: None,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: vec![],
outbound_proxy: None,
};
store
.update_account(command.clone(), &context)
.await
.unwrap();
let record = store
.load_account("acct_notes", AccountRuntimeSnapshot::default())
.await
.unwrap()
.unwrap()
.account;
assert_eq!(record.notes.as_deref(), Some("团队备用\n下月续费"));
assert_eq!(record.credential_revision.get(), 1);
let changed_fields: Vec<String> =
sqlx::query_scalar("select changed_fields from admin_audit_events")
.fetch_one(&database.pool)
.await
.unwrap();
assert!(changed_fields.iter().any(|field| field == "notes"));
store
.update_account(
UpdateAccount {
notes: None,
..command.clone()
},
&context,
)
.await
.unwrap();
store
.batch_update_accounts(
BatchUpdateAccounts {
account_ids: vec!["acct_notes".to_owned()],
enabled: false,
concurrency_limit: None,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: vec![],
outbound_proxy: None,
},
&context,
)
.await
.unwrap();
repository
.import_provider_accounts(ImportProviderAccounts {
scope: ProviderAccountAdminScope {
provider_kind: "openai".to_owned(),
},
accounts: vec![account("acct_notes", "notes-user")],
settings: None,
outbound_proxy: None,
audit: audit("audit_notes_import", "import", "acct_notes"),
})
.await
.unwrap();
let records = repository.list_provider_accounts(None, true).await.unwrap();
assert_eq!(records[0].notes.as_deref(), Some("团队备用\n下月续费"));
let audits: String = sqlx::query_scalar("select json_agg(a)::text from admin_audit_events a")
.fetch_one(&database.pool)
.await
.unwrap();
assert!(!audits.contains("团队备用"));
repository
.import_provider_accounts(ImportProviderAccounts {
scope: ProviderAccountAdminScope {
provider_kind: "openai".to_owned(),
},
accounts: vec![account("acct_notes", "notes-user")],
settings: Some(gateway_admin::model::accounts::AccountImportSettings {
notes: None,
enabled: true,
concurrency_limit: None,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: vec![],
}),
outbound_proxy: None,
audit: audit("audit_notes_reimport", "import", "acct_notes"),
})
.await
.unwrap();
assert_eq!(
repository
.load_provider_account("acct_notes")
.await
.unwrap()
.unwrap()
.summary
.notes
.as_deref(),
Some("团队备用\n下月续费")
);
store
.update_account(
UpdateAccount {
notes: Some(" \n\t ".to_owned()),
..command
},
&context,
)
.await
.unwrap();
assert_eq!(
store
.load_account("acct_notes", AccountRuntimeSnapshot::default())
.await
.unwrap()
.unwrap()
.account
.notes,
None
);
database.close().await;
}
#[tokio::test]
async fn invalid_account_notes_roll_back_scheduling_revision_and_audit() {
let Some(database) = TestDatabase::create("account_notes_rollback").await else {
return;
};
let repository = PgProviderAccountRepository::new(database.pool.clone());
repository
.insert_provider_account(account("acct_notes", "notes-user"))
.await
.unwrap();
let before = repository
.load_provider_account("acct_notes")
.await
.unwrap()
.unwrap();
let revision: i64 =
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
.fetch_one(&database.pool)
.await
.unwrap();
let result = admin_account_store(&database.pool)
.update_account(
UpdateAccount {
account_id: "acct_notes".to_owned(),
notes: Some("备".repeat(501)),
enabled: false,
concurrency_limit: None,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: vec![],
outbound_proxy: None,
},
&MutationContext {
actor: MutationActor::System,
request_id: "notes-rejected".to_owned(),
},
)
.await;
assert!(result.is_err());
assert_eq!(
repository
.load_provider_account("acct_notes")
.await
.unwrap()
.unwrap(),
before
);
assert_eq!(
sqlx::query_scalar::<_, i64>("select config_revision from runtime_settings where id = 1")
.fetch_one(&database.pool)
.await
.unwrap(),
revision
);
assert_eq!(
sqlx::query_scalar::<_, i64>("select count(*) from admin_audit_events")
.fetch_one(&database.pool)
.await
.unwrap(),
0
);
database.close().await;
}
#[tokio::test]
async fn account_recovery_resets_status_facts_without_changing_credentials_or_scheduling() {
const GROUP_ID: &str = "grp_00000000000000000000000000000070";
@@ -1547,7 +1751,8 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser
let Some(database) = TestDatabase::create("provider_account_authorization_upsert").await else {
return;
};
PgProviderAccountRepository::new(database.pool.clone())
let repository = PgProviderAccountRepository::new(database.pool.clone());
repository
.insert_provider_account(account(
"acct_authorization_existing",
"user-authorization-upsert",
@@ -1569,6 +1774,7 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser
.commit_authorization(
AuthorizationCommit {
settings: Some(gateway_admin::model::accounts::AccountImportSettings {
notes: Some(" OAuth 新建备注 ".to_owned()),
enabled: false,
concurrency_limit: None,
weight: gateway_core::account::AccountWeight::new(9).expect("weight"),
@@ -1617,6 +1823,17 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser
);
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));
assert_eq!(
repository
.load_provider_account("acct_authorization_existing")
.await
.unwrap()
.unwrap()
.summary
.notes
.as_deref(),
Some("OAuth 新建备注")
);
database.close().await;
}
@@ -2071,6 +2288,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
let revision = repository
.batch_update_provider_accounts_admin(BatchUpdateProviderAccountsAdmin {
notes: None,
outbound_proxy: None,
account_ids: vec!["acct_admin_a".to_owned()],
enabled: false,
@@ -2637,6 +2855,7 @@ async fn proxy_edit_preserves_an_inflight_token_refresh() {
admin_account_store(&database.pool)
.update_account(
UpdateAccount {
notes: None,
account_id: id.as_str().to_owned(),
enabled: true,
concurrency_limit: None,
@@ -2693,6 +2912,7 @@ async fn account_import_settings_apply_atomically_to_new_and_existing_identities
.await
.expect("seed group");
let settings = AccountImportSettings {
notes: Some(" 批量新建\n团队备用 ".to_owned()),
enabled: false,
concurrency_limit: Some(AccountConcurrencyLimit::new(3).expect("concurrency")),
weight: AccountWeight::new(7).expect("weight"),
@@ -2725,51 +2945,78 @@ async fn account_import_settings_apply_atomically_to_new_and_existing_identities
.expect("saved settings");
assert_eq!(row, (false, Some(3), 7));
assert_eq!(account_group_ids(&database.pool, id).await, [GROUP_ID]);
assert_eq!(
repository
.load_provider_account(id)
.await
.unwrap()
.unwrap()
.summary
.notes
.as_deref(),
Some("批量新建\n团队备用")
);
}
let before = repository
.load_provider_account("acct_existing_settings")
.await
.expect("existing account");
let failed = repository
.import_provider_accounts(ImportProviderAccounts {
outbound_proxy: None,
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"),
for invalid_settings in [
AccountImportSettings {
group_ids: vec![
AccountGroupId::new("grp_00000000000000000000000000000092").expect("missing group"),
],
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")
..settings.clone()
},
AccountImportSettings {
notes: Some("备".repeat(501)),
..settings
},
] {
let failed = repository
.import_provider_accounts(ImportProviderAccounts {
outbound_proxy: None,
settings: Some(invalid_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]
);
assert_eq!(
sqlx::query_scalar::<_, i64>(
"select count(*) from admin_audit_events where id = 'audit_import_settings_failed'"
)
.fetch_one(&database.pool)
.await
.expect("unchanged account"),
before
);
assert_eq!(
account_group_ids(&database.pool, "acct_existing_settings").await,
[GROUP_ID]
);
.unwrap(),
0
);
}
database.close().await;
}
@@ -34,6 +34,7 @@ fn success() -> ProxyTestResult {
fn update(account_id: &str, selection: AccountProxySelection) -> UpdateAccount {
UpdateAccount {
notes: None,
account_id: account_id.to_owned(),
enabled: true,
concurrency_limit: None,
@@ -1303,6 +1303,7 @@ fn provider_ports_with_catalog(
fn account_record(account: &ProviderAccount) -> AccountRecord {
let now = Utc::now();
AccountRecord {
notes: None,
outbound_proxy: None,
id: account.id().to_string(),
provider_kind: account.provider().clone(),
@@ -612,6 +612,7 @@ fn provider_ports_with_catalog(
fn account_record(account: &ProviderAccount) -> AccountRecord {
let now = Utc::now();
AccountRecord {
notes: None,
outbound_proxy: None,
id: account.id().to_string(),
provider_kind: account.provider().clone(),
+1
View File
@@ -4,3 +4,4 @@ b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.s
5e4bfd0b47a4de5e99b7ae1d89e0294de61856a40fba3480572db765147c3b3a 0004_remove_client_charge_reconciliation.sql
2741fabb6ec23e7a19962e364ffdc054f81584c868e24453e33644b96ccca19b 0005_managed_outbound_proxies.sql
393968715af911bb09f6738472678fbedcdc0a0967c39711610f52b9a2994c3f 0006_custom_client_keys.sql
18e8c1b71c896a9d576d9e4ef9701506f654f55383af908898195b9eb2980fde 0007_account_notes.sql
@@ -0,0 +1,4 @@
-- 备注由管理员维护,独立于上游身份和凭据刷新。
alter table provider_accounts
add column notes text
check (notes is null or char_length(notes) <= 500);
+9 -4
View File
@@ -258,7 +258,7 @@ OpenAI 明确返回 `server_is_overloaded`、`slow_down` 或模型容量不足
| `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 |
| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, outboundProxyId?, outboundProxyUrl? }` | 一次更新账号调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 |
| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, notes?, outboundProxyId?, outboundProxyUrl? }` | 一次更新账号备注、调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 |
| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled, concurrencyLimit, weight, groupIds, outboundProxyId?, outboundProxyUrl? }` | 一次事务统一更新所选账号的调度字段、完整分组集合与可选代理 |
| `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 |
| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 |
@@ -281,6 +281,10 @@ OpenAI 明确返回 `server_is_overloaded`、`slow_down` 或模型容量不足
- `sortBy`: `email`、`status`、`planType`、`usage`、`lastUsedAt`、`expiresAt`;
- `sortDirection`: `asc`、`desc`。
账号列表和详情返回 `notes`(无备注时为 `null`)。编辑时省略或 `null` 保留原备注;字符串最多 500 个 Unicode
字符,允许换行和制表符,保存时去除首尾空白,空字符串清空备注。备注独立于上游身份,导入时未显式提供备注、
重新授权、凭据刷新及批量调度更新均保留已有备注。
账号视图和 Dashboard 账号概览中的 `planType` 保留原始套餐值;`planTypeDisplay` 由后端先按 Provider 解析名称,
再统一为大驼峰格式,前端直接展示该字段,例如 `Free`、`SuperGrokPro`、`EduPlus`。
OpenAI 的 `self_serve_business_prolite` 等 Team 套餐显示为 `Business`;新套餐也使用相同格式。
@@ -394,11 +398,12 @@ OAuth 等待回调期间不持有保护;提交仍拒绝已删除、连接配
RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token 写入日志、issue、fixture 或文档。
账号导入与首次 OAuth complete 可附带 `settings: { enabled, concurrencyLimit, weight, groupIds }`。
提供 `settings` 时四项均必填,`concurrencyLimit: null` 继承运行参数,否则为 1–4294967295 的整数;
账号导入与首次 OAuth complete 可附带 `settings: { enabled, concurrencyLimit, weight, groupIds, notes? }`。
提供 `settings` 时前四项均必填,`concurrencyLimit: null` 继承运行参数,否则为 1–4294967295 的整数;
`weight` 为 1–100,`groupIds` 为完整分组集合。设置应用于本次导入的全部账号,包括匹配到的已有账号,
与凭据在同一事务内提交;分组不存在时整次回滚。省略 `settings` 时新账号使用默认设置并保持未分组,
已有账号保留原有分组、权重与并发设置。重新授权不接受 `settings`,普通 credential refresh/rotation 也保留账号设置。
已有账号保留原有分组、权重与并发设置。可选 `notes` 与编辑备注使用相同的校验和清空语义,省略或 `null` 保留已有备注;
管理端新建表单留空时省略 `notes`。重新授权不接受 `settings`,普通 credential refresh/rotation 也保留账号设置。
账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。
+3
View File
@@ -115,6 +115,7 @@ export interface Account {
outboundProxyEndpoint: string | null
id: string
name: string
notes: string | null
provider: string
resourceRef: string
email: string | null
@@ -344,6 +345,7 @@ interface AccountUpdateParam {
outboundProxyUrl?: string
outboundProxyId?: string
accountId: string
notes?: string
enabled: boolean
concurrencyLimit: number | null
weight: number
@@ -366,6 +368,7 @@ interface AccountDeleteParams {
}
interface AccountImportSettings {
notes?: string
enabled: boolean
concurrencyLimit: number | null
weight: number
+86 -62
View File
@@ -2,10 +2,11 @@
import type { CSSProperties } from 'vue'
import { Check, ChevronDown } from '@lucide/vue'
import { onClickOutside, useEventListener, useThrottleFn, whenever } from '@vueuse/core'
import { onClickOutside, useEventListener, useThrottleFn } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { computed, inject, nextTick, ref, useAttrs, useId, watch } from 'vue'
import { formFieldKey } from './BaseForm/context'
import BaseScrollbar from './BaseScrollbar.vue'
type SelectSize = 'sm' | 'md' | 'lg'
@@ -43,9 +44,12 @@ const field = inject(formFieldKey, null)
const rootRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLButtonElement | null>(null)
const popoverRef = ref<HTMLElement | null>(null)
const listboxRef = ref<HTMLElement | null>(null)
const scrollbarRef = ref<InstanceType<typeof BaseScrollbar> | null>(null)
const open = ref(false)
const activeIndex = ref(-1)
const popoverStyle = ref<CSSProperties>({})
const popoverMaxHeight = ref('244px')
const selectId = `base-select-${useId()}`
const controlId = computed(() => (typeof attrs.id === 'string' ? attrs.id : (field?.controlId.value ?? selectId)))
const invalid = computed(() =>
@@ -109,11 +113,6 @@ const triggerClasses = computed(() => [
],
])
const popoverClasses = computed(() => [
'fixed z-50 flex flex-col gap-1 rounded-cp-lg border-0 bg-cp-bg-elevated p-1 shadow-cp',
props.options.length > 6 ? 'cp-scrollbar overflow-y-auto' : 'overflow-visible',
])
function optionId(index: number) {
return `${selectId}-option-${index}`
}
@@ -137,32 +136,47 @@ function setActiveToSelected() {
}
function updatePopoverPosition() {
if (!open.value || !triggerRef.value)
if (!open.value || !triggerRef.value || !listboxRef.value)
return
const rect = triggerRef.value.getBoundingClientRect()
const gap = 6
const estimatedMenuHeight = clamp(props.options.length * 34 + 8, 42, 244)
const belowSpace = window.innerHeight - rect.bottom - gap
const aboveSpace = rect.top - gap
const placeAbove = belowSpace < estimatedMenuHeight && aboveSpace > belowSpace
const availableHeight = Math.max(placeAbove ? aboveSpace : belowSpace, 120)
const maxHeight = clamp(estimatedMenuHeight, 0, availableHeight)
const menuHeight = Math.min(listboxRef.value.scrollHeight, 244)
const belowSpace = window.innerHeight - rect.bottom - gap - 8
const aboveSpace = rect.top - gap - 8
const placeAbove = belowSpace < menuHeight && aboveSpace > belowSpace
const availableHeight = Math.max(placeAbove ? aboveSpace : belowSpace, 0)
const maxHeight = Math.min(menuHeight, availableHeight)
const top = placeAbove
? Math.max(8, rect.top - maxHeight - gap)
: Math.min(rect.bottom + gap, window.innerHeight - maxHeight - 8)
const left = clamp(rect.left, 8, window.innerWidth - rect.width - 8)
popoverMaxHeight.value = `${maxHeight}px`
popoverStyle.value = {
left: `${left}px`,
top: `${top}px`,
width: `${rect.width}px`,
maxHeight: `${maxHeight}px`,
}
}
const updatePopoverPositionThrottled = useThrottleFn(updatePopoverPosition, 32, true)
function scrollActiveIntoView() {
const wrap = scrollbarRef.value?.wrapRef
const option = listboxRef.value?.children[activeIndex.value]
if (!open.value || !wrap || !option)
return
// 只滚动菜单自身,避免键盘定位选项时带动弹窗或页面滚动。
const wrapRect = wrap.getBoundingClientRect()
const optionRect = option.getBoundingClientRect()
if (optionRect.top < wrapRect.top)
wrap.scrollTop -= wrapRect.top - optionRect.top
else if (optionRect.bottom > wrapRect.bottom)
wrap.scrollTop += optionRect.bottom - wrapRect.bottom
}
async function openMenu() {
if (props.disabled || open.value)
return
@@ -171,6 +185,8 @@ async function openMenu() {
setActiveToSelected()
await nextTick()
updatePopoverPosition()
await nextTick()
scrollActiveIntoView()
}
function closeMenu() {
@@ -254,7 +270,7 @@ function handleTriggerKeydown(event: KeyboardEvent) {
function optionClasses(option: SelectOption, index: number) {
return [
'flex w-full touch-manipulation items-center gap-2 rounded-cp-sm border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors motion-reduce:transition-none',
'flex w-full shrink-0 touch-manipulation items-center gap-2 rounded-cp-sm border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors motion-reduce:transition-none',
sizeConfig[props.size].option,
option.disabled
? 'cursor-not-allowed bg-transparent text-cp-text-disabled'
@@ -266,17 +282,18 @@ function optionClasses(option: SelectOption, index: number) {
]
}
whenever(open, async () => {
await nextTick()
updatePopoverPosition()
})
watch(activeIndex, scrollActiveIntoView, { flush: 'post' })
watch(
() => [props.options, model.value],
() => {
() => [props.options, props.size, model.value],
async () => {
if (!open.value)
return
setActiveToSelected()
await nextTick()
updatePopoverPosition()
await nextTick()
scrollActiveIntoView()
},
)
@@ -310,7 +327,7 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
<span
v-if="selectedOption?.description"
:title="selectedOption.description"
class="min-w-0 flex-1 truncate text-xs font-normal"
class="min-w-0 flex-1 truncate font-normal"
:class="disabled ? 'text-cp-text-disabled' : 'text-cp-text-tertiary'"
>
{{ selectedOption.description }}
@@ -335,51 +352,58 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
>
<div
v-if="open"
:id="`${selectId}-listbox`"
ref="popoverRef"
:class="popoverClasses"
class="fixed z-50 rounded-cp-lg border-0 bg-cp-bg-elevated shadow-cp"
:style="popoverStyle"
role="listbox"
:aria-labelledby="controlId"
>
<div
v-if="options.length === 0"
class="flex h-8.5 items-center rounded-cp-sm px-3 text-cp leading-none font-emphasis text-cp-text-quaternary"
>
{{ emptyText }}
</div>
<template v-else>
<button
v-for="(option, index) in options"
:id="optionId(index)"
:key="option.value"
type="button"
role="option"
:aria-selected="option.value === model"
:disabled="option.disabled"
:class="optionClasses(option, index)"
@mouseenter="activeIndex = option.disabled ? activeIndex : index"
@focus="activeIndex = option.disabled ? activeIndex : index"
@mousedown.prevent
@click="chooseOption(option, index)"
<BaseScrollbar ref="scrollbarRef" :max-height="popoverMaxHeight" class="rounded-cp-lg">
<div
:id="`${selectId}-listbox`"
ref="listboxRef"
class="flex flex-col gap-1 p-1"
role="listbox"
:aria-labelledby="controlId"
>
<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'"
<div
v-if="options.length === 0"
class="flex h-8.5 shrink-0 items-center rounded-cp-sm px-3 text-cp leading-none font-emphasis text-cp-text-quaternary"
>
{{ option.description }}
</span>
<Check
v-if="option.value === model"
class="shrink-0 text-cp-primary-text"
:size="size === 'sm' ? 13 : size === 'lg' ? 17 : 15"
/>
</button>
</template>
{{ emptyText }}
</div>
<template v-else>
<button
v-for="(option, index) in options"
:id="optionId(index)"
:key="option.value"
type="button"
role="option"
:aria-selected="option.value === model"
:disabled="option.disabled"
:class="optionClasses(option, index)"
@mouseenter="activeIndex = option.disabled ? activeIndex : index"
@focus="activeIndex = option.disabled ? activeIndex : index"
@mousedown.prevent
@click="chooseOption(option, index)"
>
<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 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"
:size="size === 'sm' ? 13 : size === 'lg' ? 17 : 15"
/>
</button>
</template>
</div>
</BaseScrollbar>
</div>
</Transition>
</Teleport>
@@ -12,12 +12,14 @@ const props = withDefaults(
disabled?: boolean
size?: TextareaSize
rows?: number
resize?: 'none' | 'both' | 'horizontal' | 'vertical'
}>(),
{
placeholder: '',
disabled: false,
size: 'md',
rows: 5,
resize: 'vertical',
},
)
@@ -52,7 +54,7 @@ const sizeClasses: Record<TextareaSize, string> = {
}
const textareaClasses = computed(() => [
'cp-scrollbar w-full resize-none rounded-cp border-0 bg-[var(--cp-input-bg)] text-cp-text shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-text-quaternary motion-reduce:transition-none',
'cp-scrollbar w-full rounded-cp border-0 bg-[var(--cp-input-bg)] text-cp-text shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-text-quaternary motion-reduce:transition-none',
'hover:bg-[var(--cp-input-hover-bg)] hover:shadow-cp-input-hover focus:bg-(--cp-input-active-bg) focus:shadow-cp-input-active',
'disabled:cursor-not-allowed disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
'leading-[1.55] font-emphasis',
@@ -68,6 +70,7 @@ const textareaClasses = computed(() => [
:id="controlId"
v-model="model"
:class="textareaClasses"
:style="{ resize: disabled ? 'none' : resize }"
:rows="rows"
:placeholder="placeholder"
:disabled="disabled"
@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { AccountCreateForm } from './model'
import type { AccountGroup } from '@/api'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
import AccountSettingsFields from '../AccountSettingsFields.vue'
import AccountProviderChooser from './AccountProviderChooser.vue'
@@ -34,5 +36,14 @@ const form = defineModel<AccountCreateForm>({ required: true })
:disabled="disabled"
:proxy-error="proxyError"
/>
<BaseFormItem label="备注">
<BaseTextarea
v-model="form.notes"
:rows="3"
:maxlength="500"
placeholder="最多 500 字,可不填。"
:disabled="disabled"
/>
</BaseFormItem>
</div>
</template>
@@ -6,6 +6,7 @@ export type AccountImportInputMode = Exclude<AccountImportMode, 'oauth'>
export interface AccountCreateForm {
provider: AccountCreateProvider | ''
notes: string
enabled: boolean
concurrencyLimit: string
weight: string
@@ -23,6 +24,7 @@ export interface AccountCreateForm {
export function emptyAccountCreateForm(): AccountCreateForm {
return {
provider: '',
notes: '',
enabled: true,
concurrencyLimit: '',
weight: '1',
@@ -50,5 +52,10 @@ 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)] }
return {
enabled: form.enabled,
...scheduling.values,
groupIds: [...new Set(form.groupIds)],
notes: form.notes.trim() || undefined,
}
}
@@ -3,7 +3,9 @@ import type { AccountRow } from '../constants'
import type { AccountGroup } from '@/api'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountIdentityCell from './AccountIdentityCell.vue'
import AccountPlanBadge from './AccountPlanBadge.vue'
@@ -21,6 +23,7 @@ const emit = defineEmits<{
}>()
const open = defineModel<boolean>({ required: true })
const notes = defineModel<string>('notes', { required: true })
const enabled = defineModel<boolean>('enabled', { required: true })
const concurrencyLimit = defineModel<string>('concurrencyLimit', { required: true })
const weight = defineModel<string>('weight', { required: true })
@@ -33,7 +36,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
<BaseModal
v-model="open"
title="编辑账号"
description="查看账号信息,并调整调度与所属分组。"
description="维护账号备注,调整调度与所属分组。"
size="md"
:dismissible="!saving"
>
@@ -68,6 +71,16 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
:endpoint="account.outboundProxyEndpoint"
:account-id="account.id"
/>
<BaseFormItem label="备注">
<BaseTextarea
v-model="notes"
:rows="3"
:maxlength="500"
placeholder="最多 500 字,留空可清除备注。"
:disabled="saving"
/>
</BaseFormItem>
</div>
<template #footer>
@@ -3,17 +3,19 @@ import type { getAccounts } from '@/api'
import { computed } from 'vue'
import { stablePresetVisualToneClass } from '../utils/visualTone'
import AccountNotesPopover from './AccountNotesPopover.vue'
import AccountPlanBadge from './AccountPlanBadge.vue'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
type AccountIdentity = Pick<AccountRow, 'id' | 'email' | 'planType' | 'planTypeDisplay'>
& Partial<Pick<AccountRow, 'accountId'>>
& Partial<Pick<AccountRow, 'accountId' | 'notes'>>
const props = withDefaults(
defineProps<{
account: AccountIdentity
size?: 'md' | 'lg'
showPlan?: boolean
showNotes?: boolean
titleMode?: 'local-part' | 'email'
metaPosition?: 'title' | 'secondary'
metaSize?: 'xs' | 'sm'
@@ -21,6 +23,7 @@ const props = withDefaults(
{
size: 'md',
showPlan: false,
showNotes: false,
titleMode: 'local-part',
metaPosition: 'title',
metaSize: 'sm',
@@ -36,8 +39,10 @@ const emailText = computed(() => {
return String(props.account.id)
})
const visibleNotes = computed(() => props.showNotes ? props.account.notes : undefined)
const displayTitle = computed(() =>
props.titleMode === 'email' ? emailText.value : emailText.value.split('@')[0],
visibleNotes.value || props.titleMode === 'email' ? emailText.value : emailText.value.split('@')[0],
)
const secondaryText = computed(() =>
@@ -74,7 +79,7 @@ const avatarToneClass = computed(() => {
</span>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 truncate text-cp font-heavy text-cp-text">
<span class="min-w-0 flex-1 truncate text-cp font-heavy text-cp-text" :title="displayTitle">
{{ displayTitle }}
</span>
<span
@@ -94,6 +99,7 @@ const avatarToneClass = computed(() => {
<slot name="meta" />
<AccountPlanBadge v-if="showPlan" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" :size="metaSize" />
</div>
<AccountNotesPopover v-else-if="visibleNotes" :notes="visibleNotes" class="mt-1" />
<div v-else-if="secondaryText" class="truncate font-emphasis" :class="secondaryClass">
{{ secondaryText }}
</div>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, shallowRef, useId, useTemplateRef, watch } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
const props = defineProps<{
notes: string
}>()
const detailId = `account-notes-${useId()}`
const notesTextRef = useTemplateRef<HTMLSpanElement>('notesText')
const open = shallowRef(false)
const viewportTarget = computed(() => open.value ? window : null)
function updateOpen(nextOpen: boolean) {
const text = notesTextRef.value
// 在实际打开前按需测量,悬停、点击和键盘入口共用同一道溢出检查。
open.value = nextOpen && Boolean(text && text.scrollWidth > text.clientWidth)
}
function recheckOpen() {
if (open.value)
updateOpen(true)
}
useEventListener(viewportTarget, 'resize', recheckOpen)
watch(() => props.notes, recheckOpen, { flush: 'post' })
</script>
<template>
<BasePopover
:model-value="open"
class="min-w-0 max-w-full"
trigger="hover-click"
placement="right"
:offset="12"
:hover-delay="240"
@update:model-value="updateOpen"
>
<template #trigger>
<button
type="button"
class="inline-flex min-w-0 max-w-full touch-manipulation items-center rounded-cp-sm border-0 bg-transparent p-0 text-left text-cp-xs leading-4 text-cp-text-tertiary outline-none transition-colors hover:text-cp-primary-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
:class="open ? 'text-cp-primary-text' : ''"
aria-label="查看账号备注"
aria-haspopup="dialog"
:aria-expanded="open"
:aria-controls="open ? detailId : undefined"
>
<span ref="notesText" class="min-w-0 truncate">{{ notes }}</span>
</button>
</template>
<section :id="detailId" class="w-max max-w-[min(20rem,calc(100vw-1rem))] overflow-hidden rounded-cp-lg" role="dialog" aria-label="账号备注">
<BaseScrollbar max-height="min(240px, calc(100dvh - 2rem))">
<div class="px-3 py-2.5 text-cp leading-5 whitespace-pre-wrap text-cp-text select-text wrap-anywhere">
{{ notes }}
</div>
</BaseScrollbar>
</section>
</BasePopover>
</template>
@@ -16,6 +16,7 @@ export function useAccountEditor(options: {
}) {
const showEditModal = shallowRef(false)
const editingAccountId = shallowRef<string | null>(null)
const notes = shallowRef('')
const schedulingEnabled = shallowRef(true)
const concurrencyLimit = shallowRef('')
const weight = shallowRef('1')
@@ -33,6 +34,7 @@ export function useAccountEditor(options: {
function open(account: AccountRow) {
editingAccountId.value = account.id
notes.value = account.notes ?? ''
proxyMode.value = 'preserve'
proxyId.value = ''
schedulingEnabled.value = account.enabled
@@ -59,6 +61,7 @@ export function useAccountEditor(options: {
await saveAction.run(async () => {
await updateAccount({
accountId,
notes: notes.value,
outboundProxyId: proxyMode.value === 'preserve' ? undefined : proxyMode.value === 'direct' ? '' : proxyId.value.trim(),
enabled: schedulingEnabled.value,
concurrencyLimit: scheduling.values.concurrencyLimit,
@@ -75,6 +78,7 @@ export function useAccountEditor(options: {
if (open || isSaving)
return
editingAccountId.value = null
notes.value = ''
proxyMode.value = 'preserve'
proxyId.value = ''
schedulingEnabled.value = true
@@ -86,6 +90,7 @@ export function useAccountEditor(options: {
return {
showEditModal,
editingAccount,
notes,
schedulingEnabled,
concurrencyLimit,
weight,
@@ -64,9 +64,12 @@ export function useAccountsQuery() {
}
async function replaceAccount(updated: AccountRow) {
// 状态变更可能影响筛选、排序及全局概览,统一回读并复用分页查询的末页回退。
// 先取消旧查询并应用接口返回的账号,避免旧响应覆盖最新行数据。
query.invalidate()
if (!await query.execute())
query.items.value = query.items.value.map(account => account.id === updated.id ? updated : account)
// 筛选、排序、概览和末页回退仍由回读校准,但不触发整表加载。
if (!await query.execute({ background: true }))
return true // 回读失败或被新查询取代时,不依据旧页面取消选择。
return query.items.value.some(account => account.id === updated.id)
}
+3 -1
View File
@@ -144,6 +144,7 @@ const {
const {
showEditModal,
editingAccount,
notes: editingNotes,
schedulingEnabled,
concurrencyLimit: editingConcurrencyLimit,
weight: editingWeight,
@@ -236,7 +237,7 @@ const {
</template>
<template #identity="{ row }">
<AccountIdentityCell :account="row" />
<AccountIdentityCell :account="row" show-notes />
</template>
<template #provider="{ row }">
@@ -349,6 +350,7 @@ const {
<AccountEditModal
v-model="showEditModal"
v-model:notes="editingNotes"
v-model:enabled="schedulingEnabled"
v-model:concurrency-limit="editingConcurrencyLimit"
v-model:weight="editingWeight"