From 0a09c1572f56b09bc3866d57c837c4e259ece136 Mon Sep 17 00:00:00 2001 From: Zyy <39424122+zyycn@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:36:34 +0800 Subject: [PATCH] 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 Co-authored-by: Codex --- .../gateway-admin/src/model/accounts.rs | 6 +- .../gateway-admin/tests/use_case/accounts.rs | 4 + .../src/admin/accounts/credentials.rs | 20 +- .../src/admin/accounts/presenter.rs | 1 + .../gateway-api/src/admin/accounts/wire.rs | 1 + .../gateway-api/tests/admin/accounts/mod.rs | 59 ++++ .../crates/gateway-api/tests/admin/errors.rs | 1 + .../src/postgres/account_groups.rs | 2 +- .../provider_accounts/admin_adapter.rs | 10 +- .../provider_accounts/admin_queries.rs | 2 +- .../src/postgres/provider_accounts/mapping.rs | 1 + .../postgres/provider_accounts/repository.rs | 32 +- .../src/postgres/provider_accounts/rows.rs | 9 +- .../tests/postgres/provider_accounts/mod.rs | 327 +++++++++++++++--- .../gateway-store/tests/postgres/proxies.rs | 1 + .../crates/providers/openai/tests/admin.rs | 1 + backend/crates/providers/xai/tests/admin.rs | 1 + backend/migrations/.frozen-sha256 | 1 + backend/migrations/0007_account_notes.sql | 4 + docs/api.md | 13 +- frontend/src/api/modules/accounts.ts | 3 + frontend/src/components/base/BaseSelect.vue | 148 ++++---- frontend/src/components/base/BaseTextarea.vue | 5 +- .../AccountCreateModal/AccountSetupFields.vue | 11 + .../components/AccountCreateModal/model.ts | 9 +- .../accounts/components/AccountEditModal.vue | 15 +- .../components/AccountIdentityCell.vue | 12 +- .../components/AccountNotesPopover.vue | 64 ++++ .../accounts/composables/useAccountEditor.ts | 5 + .../accounts/composables/useAccountsQuery.ts | 7 +- frontend/src/views/accounts/index.vue | 4 +- 31 files changed, 655 insertions(+), 124 deletions(-) create mode 100644 backend/migrations/0007_account_notes.sql create mode 100644 frontend/src/views/accounts/components/AccountNotesPopover.vue diff --git a/backend/crates/gateway-admin/src/model/accounts.rs b/backend/crates/gateway-admin/src/model/accounts.rs index eb682b6d9..bce7be4a7 100644 --- a/backend/crates/gateway-admin/src/model/accounts.rs +++ b/backend/crates/gateway-admin/src/model/accounts.rs @@ -18,9 +18,10 @@ pub use gateway_core::account::{ QuotaState, resolve_account_status, }; -/// 导入时统一应用的账号调度与分组设置;缺省时保留原有导入语义。 +/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct AccountImportSettings { + pub notes: Option, pub enabled: bool, pub concurrency_limit: Option, pub weight: AccountWeight, @@ -86,6 +87,7 @@ pub struct AccountRecord { pub provider_kind: ProviderKind, pub groups: Vec, pub name: String, + pub notes: Option, pub email: Option, pub upstream_user_id: Option, pub upstream_account_id: Option, @@ -217,6 +219,8 @@ pub struct AccountSummary { #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateAccount { pub account_id: String, + /// 缺省保留备注;空字符串清空备注。 + pub notes: Option, pub enabled: bool, pub concurrency_limit: Option, pub weight: AccountWeight, diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 05ec5cb64..27108ac26 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -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"), diff --git a/backend/crates/gateway-api/src/admin/accounts/credentials.rs b/backend/crates/gateway-api/src/admin/accounts/credentials.rs index 2f9d4d5c3..02ddd724c 100644 --- a/backend/crates/gateway-api/src/admin/accounts/credentials.rs +++ b/backend/crates/gateway-api/src/admin/accounts/credentials.rs @@ -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, pub enabled: bool, #[serde(deserialize_with = "deserialize_required_nullable")] pub concurrency_limit: Option, @@ -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 { 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, pub outbound_proxy_url: Option, pub account_id: String, + pub notes: Option, pub enabled: bool, #[serde(deserialize_with = "deserialize_required_nullable")] pub concurrency_limit: Option, @@ -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)?, diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs index ccd867481..9263992e7 100644 --- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs @@ -75,6 +75,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime) -> Ac AccountView { id: account.id.clone(), name: account.name, + notes: account.notes, provider: account.provider_kind.to_string(), groups: account .groups diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index b276300b1..9b287a6aa 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -240,6 +240,7 @@ pub struct AccountView { pub outbound_proxy_endpoint: Option, pub id: String, pub name: String, + pub notes: Option, pub provider: String, pub groups: Vec, pub resource_ref: String, diff --git a/backend/crates/gateway-api/tests/admin/accounts/mod.rs b/backend/crates/gateway-api/tests/admin/accounts/mod.rs index ee0e3a8f7..a71141a7e 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/mod.rs @@ -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 = diff --git a/backend/crates/gateway-api/tests/admin/errors.rs b/backend/crates/gateway-api/tests/admin/errors.rs index b21b45851..40ecee0cc 100644 --- a/backend/crates/gateway-api/tests/admin/errors.rs +++ b/backend/crates/gateway-api/tests/admin/errors.rs @@ -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(), diff --git a/backend/crates/gateway-store/src/postgres/account_groups.rs b/backend/crates/gateway-store/src/postgres/account_groups.rs index 56dd24a34..0cf51c203 100644 --- a/backend/crates/gateway-store/src/postgres/account_groups.rs +++ b/backend/crates/gateway-store/src/postgres/account_groups.rs @@ -165,7 +165,7 @@ impl AccountGroupStore for PgAccountGroupRepository { .collect::>(); 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, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index f9d43fa15..ee8841064 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -226,9 +226,12 @@ impl PgAdminAccountStore { .collect::>>() .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, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs index 76d92c476..02b40213c 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs @@ -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, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs index 1578db486..af5abb732 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs @@ -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, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs index b98911cbe..220ab4f7f 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs @@ -108,7 +108,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository { include_disabled: bool, ) -> StoreResult> { 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], diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs index c963a8462..a7323b59f 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs @@ -124,6 +124,7 @@ pub struct ProviderAccountSummary { pub id: String, pub provider_kind: String, pub name: String, + pub notes: Option, pub email: Option, pub upstream_user_id: Option, pub upstream_account_id: Option, @@ -316,6 +317,7 @@ impl fmt::Debug for RotateProviderAccount { pub struct BatchUpdateProviderAccountsAdmin { pub outbound_proxy: Option, pub account_ids: Vec, + pub notes: Option, pub enabled: bool, pub concurrency_limit: Option, 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")?, diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index 5dcff16e5..dccf37138 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -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 = + 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, 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; } diff --git a/backend/crates/gateway-store/tests/postgres/proxies.rs b/backend/crates/gateway-store/tests/postgres/proxies.rs index ef4e7fa12..0792b73c6 100644 --- a/backend/crates/gateway-store/tests/postgres/proxies.rs +++ b/backend/crates/gateway-store/tests/postgres/proxies.rs @@ -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, diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 4d78c4bc5..a4a2495e4 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -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(), diff --git a/backend/crates/providers/xai/tests/admin.rs b/backend/crates/providers/xai/tests/admin.rs index 569a73653..f363f6b2f 100644 --- a/backend/crates/providers/xai/tests/admin.rs +++ b/backend/crates/providers/xai/tests/admin.rs @@ -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(), diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index 4a24975e6..95079d7f8 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -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 diff --git a/backend/migrations/0007_account_notes.sql b/backend/migrations/0007_account_notes.sql new file mode 100644 index 000000000..ef0db5f61 --- /dev/null +++ b/backend/migrations/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); diff --git a/docs/api.md b/docs/api.md index 48ce5706a..6a596bf7a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 }]`。 diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 01239fcd7..bb80edfde 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -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 diff --git a/frontend/src/components/base/BaseSelect.vue b/frontend/src/components/base/BaseSelect.vue index 322a0d378..fe0e6e691 100644 --- a/frontend/src/components/base/BaseSelect.vue +++ b/frontend/src/components/base/BaseSelect.vue @@ -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(null) const triggerRef = ref(null) const popoverRef = ref(null) +const listboxRef = ref(null) +const scrollbarRef = ref | null>(null) const open = ref(false) const activeIndex = ref(-1) const popoverStyle = ref({}) +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 {{ selectedOption.description }} @@ -335,51 +352,58 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr >
-
- {{ emptyText }} -
- - + {{ emptyText }} +
+ + + + diff --git a/frontend/src/components/base/BaseTextarea.vue b/frontend/src/components/base/BaseTextarea.vue index 606799838..e78188b1a 100644 --- a/frontend/src/components/base/BaseTextarea.vue +++ b/frontend/src/components/base/BaseTextarea.vue @@ -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 = { } 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" diff --git a/frontend/src/views/accounts/components/AccountCreateModal/AccountSetupFields.vue b/frontend/src/views/accounts/components/AccountCreateModal/AccountSetupFields.vue index f788c7200..21d302d2f 100644 --- a/frontend/src/views/accounts/components/AccountCreateModal/AccountSetupFields.vue +++ b/frontend/src/views/accounts/components/AccountCreateModal/AccountSetupFields.vue @@ -1,6 +1,8 @@ + + diff --git a/frontend/src/views/accounts/composables/useAccountEditor.ts b/frontend/src/views/accounts/composables/useAccountEditor.ts index 68df03f93..80127963e 100644 --- a/frontend/src/views/accounts/composables/useAccountEditor.ts +++ b/frontend/src/views/accounts/composables/useAccountEditor.ts @@ -16,6 +16,7 @@ export function useAccountEditor(options: { }) { const showEditModal = shallowRef(false) const editingAccountId = shallowRef(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, diff --git a/frontend/src/views/accounts/composables/useAccountsQuery.ts b/frontend/src/views/accounts/composables/useAccountsQuery.ts index b1a7dd155..863a767fc 100644 --- a/frontend/src/views/accounts/composables/useAccountsQuery.ts +++ b/frontend/src/views/accounts/composables/useAccountsQuery.ts @@ -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) } diff --git a/frontend/src/views/accounts/index.vue b/frontend/src/views/accounts/index.vue index 9735734a5..facf68f83 100644 --- a/frontend/src/views/accounts/index.vue +++ b/frontend/src/views/accounts/index.vue @@ -144,6 +144,7 @@ const { const { showEditModal, editingAccount, + notes: editingNotes, schedulingEnabled, concurrencyLimit: editingConcurrencyLimit, weight: editingWeight, @@ -236,7 +237,7 @@ const {