diff --git a/backend/src/admin/monitoring/dashboard.rs b/backend/src/admin/monitoring/dashboard.rs index c1a87c0bd..6190cd2e6 100644 --- a/backend/src/admin/monitoring/dashboard.rs +++ b/backend/src/admin/monitoring/dashboard.rs @@ -1,6 +1,6 @@ //! 管理端 Dashboard 聚合视图。 -use std::{cmp::Reverse, collections::HashMap}; +use std::collections::HashMap; use axum::{ extract::{Query, State}, @@ -15,14 +15,19 @@ use crate::{ admin::{ auth::session::AdminAuth, monitoring::{ - account_usage_service::{ - AdminUsageRecord, AdminUsageSummary, AdminUsageTimeBucketRecord, - }, + account_usage_service::AdminUsageTimeBucketRecord, diagnostics::{ fingerprint_diagnostics, AccountCapacityDiagnostics, AccountPoolDiagnostics, }, - usage_record_routes::{usage_record_items, UsageRecordData}, + usage_record_model::{ + metadata_nonnegative_i64, metadata_string, UsageRecord, UsageRecordLevel, + }, + usage_record_routes::{ + usage_cost_details, usage_record_models, usage_token_details, + UsageRecordCostDetailsData, UsageRecordTokenDetailsData, + }, usage_record_service::AdminUsageRecordFilter, + usage_record_store::UsageRecordAccountUsage, }, response::{AdminEnvelope, AdminError, AdminResponse}, }, @@ -40,11 +45,15 @@ use crate::{ upstream::accounts::model::{Account, AccountStatus}, }; -const DASHBOARD_USAGE_RECORD_LIMIT: u32 = 1000; +const DASHBOARD_ACCOUNT_USAGE_LIMIT: u32 = 4; +const DASHBOARD_USAGE_RECORD_LIMIT: u32 = 10; const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15; const DASHBOARD_TIME_BUCKET_SLOTS: i64 = 7 * 24 * 4; const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4; const HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD: u64 = 3; +const FIVE_HOUR_WINDOW_SECONDS: u64 = 18_000; +const WEEK_WINDOW_SECONDS: u64 = 604_800; +const MONTH_WINDOW_SECONDS: u64 = 2_592_000; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -69,7 +78,7 @@ struct DashboardSummaryData { health_timeline: DashboardHealthTimelineData, account_usage: Vec, service_statuses: Vec, - usage_records: Vec, + usage_records: Vec, pool_summary: AccountPoolDiagnostics, capacity_info: AccountCapacityDiagnostics, rotation_strategy: Option, @@ -80,10 +89,7 @@ struct DashboardSummaryData { struct DashboardHealthTimelineData { title: String, description: String, - range_display: String, reliability_display: String, - oldest_label: String, - newest_label: String, points: String, } @@ -112,11 +118,8 @@ struct DashboardAccountsCardData { struct DashboardTrafficCardData { today_requests: String, today_requests_value: u64, - yesterday_requests: String, yesterday_requests_value: u64, total_requests: String, - rpm: String, - tpm: String, } #[derive(Debug, Serialize)] @@ -124,13 +127,9 @@ struct DashboardTrafficCardData { struct DashboardTokenCardData { today_tokens: String, today_tokens_value: u64, - yesterday_tokens: String, yesterday_tokens_value: u64, total_tokens: String, today_cost_usd: String, - today_cost_usd_value: f64, - total_cost_usd: String, - total_cost_usd_value: f64, } #[derive(Debug, Serialize)] @@ -138,12 +137,10 @@ struct DashboardTokenCardData { struct DashboardCacheCardData { today_hit_rate: String, today_hit_rate_value: Option, - yesterday_hit_rate: String, yesterday_hit_rate_value: Option, total_hit_rate: String, total_cached_tokens: String, first_token_latency_ms: String, - completion_latency_ms: String, } #[derive(Debug, Serialize)] @@ -166,7 +163,6 @@ struct DashboardTrendPointData { output_tokens_value: u64, cached_tokens: String, cached_tokens_value: u64, - tokens: String, tokens_value: u64, errors: String, errors_value: u64, @@ -191,14 +187,34 @@ struct DashboardTrendSummaryData { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct DashboardAccountUsageData { - name: String, + id: String, email: String, - plan: String, - requests: String, + plan_type: Option, tokens: String, quota_used_percent: Option, last_used: String, - status: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DashboardUsageRecordData { + id: String, + level: UsageRecordLevel, + route: Option, + model: Option, + status_code: Option, + transport: Option, + created_at_display: String, + account_email: Option, + requested_model: Option, + upstream_model: Option, + client_ip: Option, + user_agent: Option, + reasoning_effort: Option, + token_details: UsageRecordTokenDetailsData, + cost_details: Option, + first_token_latency_ms_display: String, + latency_ms_display: String, } #[derive(Debug, Serialize)] @@ -221,8 +237,14 @@ struct UsageWindow { first_token_latency_count: u64, max_first_token_bucket_latency: u64, min_first_token_bucket_latency: u64, - latency_sum: u64, - latency_count: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum QuotaWindowPriority { + FiveHour, + Weekly, + Monthly, + Other, } impl UsageWindow { @@ -250,10 +272,6 @@ impl UsageWindow { fn min_first_token_bucket_latency(self) -> Option { (self.min_first_token_bucket_latency > 0).then_some(self.min_first_token_bucket_latency) } - - fn avg_latency(self) -> Option { - self.latency_sum.checked_div(self.latency_count) - } } /// `GET /api/admin/dashboard/summary` @@ -269,53 +287,50 @@ pub(crate) async fn dashboard_summary( .await .unwrap_or_default(); let capacity = state.services.account_pool.capacity_summary_now().await; - let summary = state + let now = Utc::now(); + let today_filter = today_usage_record_filter(now); + let account_usage_records = state .services - .usage - .summary() + .usage_records + .account_usage(today_filter.clone(), DASHBOARD_ACCOUNT_USAGE_LIMIT) .await - .unwrap_or_else(|_| empty_usage_summary()); - let usage_records = state - .services - .usage - .list(None, 200) - .await - .map(|page| page.items) .unwrap_or_default(); let recent_events = state .services .usage_records - .list( - None, - DASHBOARD_USAGE_RECORD_LIMIT, - AdminUsageRecordFilter::default(), - ) + .list(None, DASHBOARD_USAGE_RECORD_LIMIT, today_filter) .await .map(|page| page.items) .unwrap_or_default(); - let now = Utc::now(); let time_buckets = dashboard_time_buckets(&state, now).await; let pool_summary = account_pool_summary(&accounts); let trend = dashboard_trend_data(&time_buckets, query.kind.unwrap_or_default()); - let quota_used_by_account = account_quota_used_percent_by_id(&state, &usage_records).await; + let quota_used_by_account = + account_quota_used_percent_by_id(&state, &account_usage_records).await; let settings = state.services.settings.current(); - let recent_usage_records = recent_events.into_iter().take(10).collect::>(); + let recent_usage_records = recent_events; let account_emails = state .services .usage_records .account_email_map(&recent_usage_records) .await .map_err(|error| AdminError::usage_record_accounts_failed(error.to_string()))?; - let dashboard_usage_records = usage_record_items(recent_usage_records, &account_emails); + let dashboard_usage_records = + dashboard_usage_record_items(recent_usage_records, &account_emails); Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(DashboardSummaryData { - cards: dashboard_cards(&accounts, &summary, &time_buckets), + cards: dashboard_cards(&accounts, &time_buckets), trend, health_timeline: dashboard_health_timeline_data(&time_buckets), - account_usage: account_usage_data(&accounts, &usage_records, "a_used_by_account), + account_usage: account_usage_data( + &accounts, + &account_usage_records, + "a_used_by_account, + now, + ), service_statuses: service_status_data(&state), usage_records: dashboard_usage_records, pool_summary, @@ -325,6 +340,14 @@ pub(crate) async fn dashboard_summary( )) } +fn today_usage_record_filter(now: DateTime) -> AdminUsageRecordFilter { + AdminUsageRecordFilter { + start_time: Some(china_day_start(now)), + end_time: Some(now), + ..AdminUsageRecordFilter::default() + } +} + /// `GET /api/admin/dashboard/trend?kind=usage|latency|errors` pub(crate) async fn dashboard_trend( State(state): State, @@ -344,15 +367,13 @@ pub(crate) async fn dashboard_trend( fn dashboard_cards( accounts: &[Account], - summary: &AdminUsageSummary, buckets: &[AdminUsageTimeBucketRecord], ) -> DashboardCardsData { - dashboard_cards_at(accounts, summary, buckets, Utc::now()) + dashboard_cards_at(accounts, buckets, Utc::now()) } fn dashboard_cards_at( accounts: &[Account], - summary: &AdminUsageSummary, buckets: &[AdminUsageTimeBucketRecord], now: DateTime, ) -> DashboardCardsData { @@ -361,10 +382,7 @@ fn dashboard_cards_at( let today = usage_window(buckets, today_start, now); let yesterday = usage_window(buckets, yesterday_start, today_start); let today_cost = cost_window(buckets, today_start, now).unwrap_or(0.0); - let total_cost = total_cost(buckets); - let total_input = nonnegative_i64_to_u64(summary.input_tokens); - let total_cached = nonnegative_i64_to_u64(summary.cached_tokens); let total_accounts = accounts.len() as u64; let enabled_accounts = accounts .iter() @@ -382,13 +400,6 @@ fn dashboard_cards_at( ) }) .count() as u64; - let total_requests = nonnegative_i64_to_u64(summary.request_count); - let total_tokens = nonnegative_i64_to_u64(summary.input_tokens + summary.output_tokens); - let total_hit_rate = if total_input > 0 { - Some(total_cached as f64 / total_input as f64) - } else { - None - }; DashboardCardsData { accounts: DashboardAccountsCardData { @@ -402,40 +413,27 @@ fn dashboard_cards_at( traffic: DashboardTrafficCardData { today_requests: format_compact_number(today.requests), today_requests_value: today.requests, - yesterday_requests: format_compact_number(yesterday.requests), yesterday_requests_value: yesterday.requests, - total_requests: format_compact_number(total_requests), - rpm: format_compact_number(today.requests), - tpm: format_tokens(today.tokens()), + total_requests: format_compact_number(today.requests), }, tokens: DashboardTokenCardData { today_tokens: format_tokens(today.tokens()), today_tokens_value: today.tokens(), - yesterday_tokens: format_tokens(yesterday.tokens()), yesterday_tokens_value: yesterday.tokens(), - total_tokens: format_tokens(total_tokens), - today_cost_usd: format_optional_cost(Some(today_cost)), - today_cost_usd_value: today_cost, - total_cost_usd: format_optional_cost(Some(total_cost)), - total_cost_usd_value: total_cost, + total_tokens: format_tokens(today.tokens()), + today_cost_usd: format_cost(today_cost), }, cache: DashboardCacheCardData { today_hit_rate: format_rate(today.cache_hit_rate()), today_hit_rate_value: today.cache_hit_rate(), - yesterday_hit_rate: format_rate(yesterday.cache_hit_rate()), yesterday_hit_rate_value: yesterday.cache_hit_rate(), - total_hit_rate: format_rate(total_hit_rate), - total_cached_tokens: format_tokens(total_cached), + total_hit_rate: format_rate(today.cache_hit_rate()), + total_cached_tokens: format_tokens(today.cached_tokens), first_token_latency_ms: format_optional_duration_ms(today.avg_first_token_latency()), - completion_latency_ms: format_optional_duration_ms(today.avg_latency()), }, } } -fn format_optional_cost(value: Option) -> String { - value.map_or_else(|| "-".to_string(), format_cost) -} - fn format_optional_duration_ms(value: Option) -> String { format_duration_ms(value.and_then(|value| i64::try_from(value).ok())) } @@ -468,8 +466,9 @@ fn dashboard_trend_data_at( now: DateTime, ) -> DashboardTrendData { let current_hour = china_hour_start(now); - let start = current_hour - Duration::hours(23); - let mut buckets = (0..24) + let start = china_day_start(now); + let elapsed_hours = current_hour.signed_duration_since(start).num_hours(); + let mut buckets = (0..=elapsed_hours) .map(|index| { let bucket_start = start + Duration::hours(index); (bucket_start, UsageWindow::default()) @@ -512,7 +511,6 @@ fn dashboard_trend_data_at( output_tokens_value: bucket.output_tokens, cached_tokens: format_tokens(bucket.cached_tokens), cached_tokens_value: bucket.cached_tokens, - tokens: format_tokens(tokens), tokens_value: tokens, errors: format_compact_number(bucket.errors), errors_value: bucket.errors, @@ -586,14 +584,11 @@ fn dashboard_health_timeline_data_at( DashboardHealthTimelineData { title: "请求健康时间线".to_string(), description: "今日请求可靠性".to_string(), - range_display: format!("{} - {}", china_datetime(&start), china_datetime(&now)), reliability_display: if requests > 0 { format!("{reliability:.1}%") } else { "-".to_string() }, - oldest_label: "00:00".to_string(), - newest_label: "24:00".to_string(), points: buckets .into_iter() .map(|(bucket_start, bucket)| { @@ -759,22 +754,6 @@ fn cost_window( has_usage.then_some(total) } -fn total_cost(records: &[AdminUsageTimeBucketRecord]) -> f64 { - let mut total = 0.0; - let mut has_usage = false; - for record in records { - if let Some(cost) = record.cost_usd { - total += cost; - has_usage = true; - } - } - if has_usage { - return total; - } - - 0.0 -} - fn apply_bucket(window: &mut UsageWindow, record: &AdminUsageTimeBucketRecord) { window.requests += nonnegative_i64_to_u64(record.request_count); window.input_tokens += nonnegative_i64_to_u64(record.input_tokens); @@ -792,8 +771,6 @@ fn apply_bucket(window: &mut UsageWindow, record: &AdminUsageTimeBucketRecord) { window.min_first_token_bucket_latency.min(latency) }; } - window.latency_sum += nonnegative_i64_to_u64(record.latency_sum); - window.latency_count += nonnegative_i64_to_u64(record.latency_count); } fn first_token_bucket_latency(record: &AdminUsageTimeBucketRecord) -> Option { @@ -822,68 +799,51 @@ fn account_pool_summary(accounts: &[Account]) -> AccountPoolDiagnostics { fn account_usage_data( accounts: &[Account], - usage_records: &[AdminUsageRecord], + usage_records: &[UsageRecordAccountUsage], quota_used_by_account: &HashMap, + now: DateTime, ) -> Vec { let account_by_id = accounts .iter() .map(|account| (account.id.as_str(), account)) .collect::>(); - let mut records = usage_records.iter().collect::>(); - records.sort_by_key(|record| Reverse((record.last_used_at, record.account_id.clone()))); - let mut rows = records + usage_records .iter() .map(|usage| { let account = account_by_id.get(usage.account_id.as_str()).copied(); - let name = account - .and_then(|account| account.label.clone()) - .or_else(|| usage.label.clone()) - .or_else(|| { - usage - .email - .as_ref() - .and_then(|email| email.split('@').next().map(ToString::to_string)) - }) - .unwrap_or_else(|| usage.account_id.chars().take(8).collect()); - let status = account.map_or_else( - || "active".to_string(), - |account| account.status.to_string(), - ); let quota_used_percent = quota_used_by_account .get(&usage.account_id) .copied() - .or_else(|| (status == "quota_exhausted").then_some(100.0)); - let requests = nonnegative_i64_to_u64(usage.request_count); - let tokens = nonnegative_i64_to_u64(usage.input_tokens + usage.output_tokens); + .or_else(|| { + matches!( + account.map(|account| account.status), + Some(AccountStatus::QuotaExhausted) + ) + .then_some(100.0) + }); DashboardAccountUsageData { - name, - email: usage.email.clone().unwrap_or_else(|| "-".to_string()), - plan: usage - .plan_type - .clone() - .unwrap_or_else(|| "free".to_string()), - requests: format_compact_number(requests), - tokens: format_tokens(tokens), + id: usage.account_id.clone(), + email: account + .and_then(|account| account.email.as_ref()) + .cloned() + .expect("dashboard account usage requires email"), + plan_type: account + .and_then(|account| account.plan_type.as_ref()) + .cloned(), + tokens: format_tokens(usage.total_tokens), quota_used_percent, - last_used: china_relative_time(usage.last_used_at, Utc::now()), - status, + last_used: china_relative_time(Some(usage.last_used_at), now), } }) - .collect::>(); - rows.truncate(4); - rows + .collect() } async fn account_quota_used_percent_by_id( state: &AppState, - usage_records: &[AdminUsageRecord], + usage_records: &[UsageRecordAccountUsage], ) -> HashMap { - let mut records = usage_records.iter().collect::>(); - records.sort_by_key(|record| Reverse((record.last_used_at, record.account_id.clone()))); - records.truncate(4); - - let mut quota_used_by_account = HashMap::with_capacity(records.len()); - for usage in records { + let mut quota_used_by_account = HashMap::with_capacity(usage_records.len()); + for usage in usage_records { let Ok(Some(quota_json)) = state .services .accounts @@ -901,27 +861,67 @@ async fn account_quota_used_percent_by_id( fn quota_used_percent(quota_json: &str) -> Option { let quota = serde_json::from_str::(quota_json).ok()?; - let mut values = Vec::new(); - if let Some(used_percent) = quota - .pointer("/monthly_limit/used_percent") - .and_then(percent_value) - { - values.push(used_percent); + let mut selected = None; + if let Some(monthly_limit) = quota.get("monthly_limit") { + select_quota_used_percent( + &mut selected, + quota_window_priority(monthly_limit, QuotaWindowPriority::Monthly), + monthly_limit.get("used_percent").and_then(percent_value), + ); } if let Some(snapshots) = quota.get("snapshots").and_then(Value::as_array) { for snapshot in snapshots { for role in ["primary", "secondary"] { - if let Some(used_percent) = snapshot - .get(role) - .and_then(|window| window.get("used_percent")) - .and_then(percent_value) - { - values.push(used_percent); + if let Some(window) = snapshot.get(role) { + select_quota_used_percent( + &mut selected, + quota_window_priority(window, QuotaWindowPriority::Other), + window.get("used_percent").and_then(percent_value), + ); } } } } - values.into_iter().max_by(f64::total_cmp) + selected.map(|(_, used_percent)| used_percent) +} + +fn select_quota_used_percent( + selected: &mut Option<(QuotaWindowPriority, f64)>, + priority: QuotaWindowPriority, + used_percent: Option, +) { + let Some(used_percent) = used_percent else { + return; + }; + match selected { + Some((selected_priority, selected_percent)) + if priority > *selected_priority + || (priority == *selected_priority && used_percent <= *selected_percent) => {} + _ => *selected = Some((priority, used_percent)), + } +} + +fn quota_window_priority(window: &Value, fallback: QuotaWindowPriority) -> QuotaWindowPriority { + let window_seconds = window + .get("window_minutes") + .and_then(Value::as_u64) + .and_then(|minutes| minutes.checked_mul(60)); + match window_seconds { + Some(seconds) if quota_window_matches(seconds, FIVE_HOUR_WINDOW_SECONDS) => { + QuotaWindowPriority::FiveHour + } + Some(seconds) if quota_window_matches(seconds, WEEK_WINDOW_SECONDS) => { + QuotaWindowPriority::Weekly + } + Some(seconds) if quota_window_matches(seconds, MONTH_WINDOW_SECONDS) => { + QuotaWindowPriority::Monthly + } + _ => fallback, + } +} + +fn quota_window_matches(actual: u64, expected: u64) -> bool { + actual > 0 && actual.abs_diff(expected) <= expected / 20 } fn percent_value(value: &Value) -> Option { @@ -931,6 +931,64 @@ fn percent_value(value: &Value) -> Option { percent.is_finite().then_some(percent.clamp(0.0, 100.0)) } +fn dashboard_usage_record_items( + items: Vec, + account_emails: &HashMap, +) -> Vec { + items + .into_iter() + .map(|record| dashboard_usage_record_data(record, account_emails)) + .collect() +} + +fn dashboard_usage_record_data( + record: UsageRecord, + account_emails: &HashMap, +) -> DashboardUsageRecordData { + let account_email = record + .account_id + .as_deref() + .and_then(|account_id| account_emails.get(account_id)) + .cloned() + .or_else(|| metadata_string(&record.metadata, &["accountEmail", "account_email"])); + let (requested_model, upstream_model) = usage_record_models(&record); + let client_ip = metadata_string(&record.metadata, &["clientIp", "ipAddress", "ip_address"]); + let user_agent = metadata_string(&record.metadata, &["userAgent", "user_agent"]); + let reasoning_effort = + metadata_string(&record.metadata, &["reasoningEffort", "reasoning_effort"]); + let token_details = usage_token_details(&record.metadata); + let cost_details = usage_cost_details(&record, upstream_model.as_deref(), &token_details); + let first_token_latency_ms = metadata_nonnegative_i64( + &record.metadata, + &[ + "firstTokenMs", + "first_token_ms", + "firstTokenLatencyMs", + "first_token_latency_ms", + ], + ); + + DashboardUsageRecordData { + id: record.id, + level: record.level, + route: record.route, + model: record.model, + status_code: record.status_code, + transport: record.transport, + created_at_display: china_datetime(&record.created_at), + account_email, + requested_model, + upstream_model, + client_ip, + user_agent, + reasoning_effort, + token_details, + cost_details, + first_token_latency_ms_display: format_duration_ms(first_token_latency_ms), + latency_ms_display: format_duration_ms(record.latency_ms), + } +} + fn service_status_data(state: &AppState) -> Vec { let fingerprint = fingerprint_diagnostics(&state.services.fingerprint); vec![ @@ -993,20 +1051,3 @@ fn format_fingerprint_updated_at(value: Option) -> String { } china_datetime_rfc3339_str(value) } - -fn empty_usage_summary() -> AdminUsageSummary { - AdminUsageSummary { - account_count: 0, - request_count: 0, - empty_response_count: 0, - input_tokens: 0, - output_tokens: 0, - cached_tokens: 0, - reasoning_tokens: 0, - total_tokens: 0, - image_input_tokens: 0, - image_output_tokens: 0, - image_request_count: 0, - image_request_failed_count: 0, - } -} diff --git a/backend/src/admin/monitoring/usage_record_routes.rs b/backend/src/admin/monitoring/usage_record_routes.rs index f5b997fca..57f1eedc6 100644 --- a/backend/src/admin/monitoring/usage_record_routes.rs +++ b/backend/src/admin/monitoring/usage_record_routes.rs @@ -545,7 +545,7 @@ fn usage_record_data( } } -fn usage_record_models(record: &UsageRecord) -> (Option, Option) { +pub(crate) fn usage_record_models(record: &UsageRecord) -> (Option, Option) { let stored_model = record .model .as_deref() @@ -560,7 +560,7 @@ fn usage_record_models(record: &UsageRecord) -> (Option, Option) (requested_model, upstream_model) } -fn usage_token_details(metadata: &Value) -> UsageRecordTokenDetailsData { +pub(crate) fn usage_token_details(metadata: &Value) -> UsageRecordTokenDetailsData { let input_tokens = metadata_usage_u64(metadata, "inputTokens"); let output_tokens = metadata_usage_u64(metadata, "outputTokens"); let cached_tokens = metadata_usage_u64(metadata, "cachedTokens"); @@ -582,7 +582,7 @@ fn usage_token_details(metadata: &Value) -> UsageRecordTokenDetailsData { } } -fn usage_cost_details( +pub(crate) fn usage_cost_details( record: &UsageRecord, upstream_model: Option<&str>, tokens: &UsageRecordTokenDetailsData, diff --git a/backend/src/admin/monitoring/usage_record_service.rs b/backend/src/admin/monitoring/usage_record_service.rs index 4cdb3c180..f83238203 100644 --- a/backend/src/admin/monitoring/usage_record_service.rs +++ b/backend/src/admin/monitoring/usage_record_service.rs @@ -11,8 +11,9 @@ use crate::{ usage_record_model::metadata_service_tier, usage_record_model::{UsageRecord, UsageRecordLevel}, usage_record_store::{ - SqliteUsageRecordStore, UsageRecordBreakdown, UsageRecordEndpointSource, - UsageRecordFilter, UsageRecordModelSource, UsageRecordSummary, UsageRecordTrendPoint, + SqliteUsageRecordStore, UsageRecordAccountUsage, UsageRecordBreakdown, + UsageRecordEndpointSource, UsageRecordFilter, UsageRecordModelSource, + UsageRecordSummary, UsageRecordTrendPoint, }, }, infra::json::{NumberedPage, Page}, @@ -212,6 +213,18 @@ impl AdminUsageRecordService { .map_err(|_| AdminUsageRecordError::List) } + /// 按账号聚合使用记录。 + pub async fn account_usage( + &self, + filter: AdminUsageRecordFilter, + limit: u32, + ) -> Result, AdminUsageRecordError> { + self.store + .account_usage(filter.into(), limit) + .await + .map_err(|_| AdminUsageRecordError::List) + } + /// 按模型来源聚合使用记录分布。 pub async fn model_distribution( &self, diff --git a/backend/src/admin/monitoring/usage_record_store.rs b/backend/src/admin/monitoring/usage_record_store.rs index 56da84367..4a6ecc1b7 100644 --- a/backend/src/admin/monitoring/usage_record_store.rs +++ b/backend/src/admin/monitoring/usage_record_store.rs @@ -198,6 +198,15 @@ impl SqliteUsageRecordStore { usage_summary(&self.pool, &filter).await } + /// 按账号聚合使用记录。 + pub async fn account_usage( + &self, + filter: UsageRecordFilter, + limit: u32, + ) -> SqliteUsageRecordStoreResult> { + usage_account_usage(&self.pool, &filter, limit).await + } + /// 按模型来源聚合使用记录分布。 pub async fn model_distribution( &self, @@ -477,6 +486,45 @@ async fn usage_summary( }) } +async fn usage_account_usage( + pool: &SqlitePool, + filter: &UsageRecordFilter, + limit: u32, +) -> SqliteUsageRecordStoreResult> { + let mut builder = QueryBuilder::::new( + "select + account_id, + sum(coalesce(cast(json_extract(metadata_json, '$.usage.inputTokens') as integer), cast(json_extract(metadata_json, '$.inputTokens') as integer), 0)) as input_tokens, + sum(coalesce(cast(json_extract(metadata_json, '$.usage.outputTokens') as integer), cast(json_extract(metadata_json, '$.outputTokens') as integer), 0)) as output_tokens, + max(created_at) as last_used_at + from usage_records", + ); + push_filter(&mut builder, filter, None)?; + builder.push(" and account_id is not null and trim(account_id) <> ''"); + builder.push(" group by account_id order by last_used_at desc, account_id asc limit "); + builder.push_bind(i64::from(limit.clamp(1, 50))); + + builder + .build() + .fetch_all(pool) + .await? + .into_iter() + .map(|row| { + let input_tokens = + optional_nonnegative_i64_to_u64(row.get::, _>("input_tokens")); + let output_tokens = + optional_nonnegative_i64_to_u64(row.get::, _>("output_tokens")); + Ok(UsageRecordAccountUsage { + account_id: row.get("account_id"), + input_tokens, + output_tokens, + total_tokens: input_tokens + output_tokens, + last_used_at: parse_rfc3339(&row.get::("last_used_at"))?, + }) + }) + .collect() +} + async fn usage_record_account_email_map( pool: &SqlitePool, items: &[UsageRecord], @@ -978,6 +1026,21 @@ pub struct UsageRecordSummary { pub average_latency_ms: Option, } +/// 账号使用记录聚合。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsageRecordAccountUsage { + /// 账号 ID。 + pub account_id: String, + /// 输入 Token。 + pub input_tokens: u64, + /// 输出 Token。 + pub output_tokens: u64, + /// 总 Token。 + pub total_tokens: u64, + /// 最近使用时间。 + pub last_used_at: DateTime, +} + /// 模型分布来源。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UsageRecordModelSource { diff --git a/backend/src/upstream/models/service.rs b/backend/src/upstream/models/service.rs index e98c50b98..4d28cd36b 100644 --- a/backend/src/upstream/models/service.rs +++ b/backend/src/upstream/models/service.rs @@ -43,11 +43,17 @@ pub enum ModelServiceError { #[error("no active accounts available for model refresh")] NoAccounts, /// 快照写入失败。 - #[error("failed to store model snapshot")] - StoreSnapshot, + #[error("failed to store model snapshot: {source}")] + StoreSnapshot { + #[source] + source: ModelSnapshotStoreError, + }, /// 刷新后重新读取快照失败。 - #[error("failed to load model snapshots")] - LoadSnapshots, + #[error("failed to load model snapshots: {source}")] + LoadSnapshots { + #[source] + source: ModelSnapshotStoreError, + }, /// 所有计划都刷新失败。 #[error("all model refresh plans failed")] AllPlansFailed(ModelRefreshResult), @@ -251,10 +257,10 @@ impl ModelService { } } -fn map_store_snapshot_error(_: ModelSnapshotStoreError) -> ModelServiceError { - ModelServiceError::StoreSnapshot +fn map_store_snapshot_error(source: ModelSnapshotStoreError) -> ModelServiceError { + ModelServiceError::StoreSnapshot { source } } -fn map_load_snapshots_error(_: ModelSnapshotStoreError) -> ModelServiceError { - ModelServiceError::LoadSnapshots +fn map_load_snapshots_error(source: ModelSnapshotStoreError) -> ModelServiceError { + ModelServiceError::LoadSnapshots { source } } diff --git a/backend/src/upstream/models/snapshot_store.rs b/backend/src/upstream/models/snapshot_store.rs index 40882381e..045794178 100644 --- a/backend/src/upstream/models/snapshot_store.rs +++ b/backend/src/upstream/models/snapshot_store.rs @@ -7,7 +7,7 @@ use thiserror::Error; use super::{info::CodexModelInfo, snapshot::ModelPlanSnapshot}; /// 模型快照存储错误。 -#[derive(Debug, Error)] +#[derive(Debug, Error, PartialEq, Eq)] pub enum ModelSnapshotStoreError { /// 底层存储失败。 #[error("model snapshot store operation failed: {message}")] diff --git a/backend/tests/admin/monitoring/dashboard.rs b/backend/tests/admin/monitoring/dashboard.rs index 20492809c..bd3a3c0f3 100644 --- a/backend/tests/admin/monitoring/dashboard.rs +++ b/backend/tests/admin/monitoring/dashboard.rs @@ -14,7 +14,7 @@ use codex_proxy_rs::{ }, infra::{ database::connect_sqlite, - time::{china_day_start, china_hour, china_hour_start}, + time::{china_datetime, china_day_start, china_hour, china_hour_start}, }, proxy::dispatch::session_affinity::SqliteSessionAffinityStore, runtime::{ @@ -96,22 +96,22 @@ async fn dashboard_summary_should_calculate_today_traffic_and_latency_from_time_ let body = dashboard_summary(app).await; - assert_eq!(body["data"]["cards"]["traffic"]["rpm"], "2"); - assert_eq!(body["data"]["cards"]["traffic"]["tpm"], "30"); assert_eq!(body["data"]["cards"]["traffic"]["todayRequests"], "2"); assert_eq!(body["data"]["cards"]["traffic"]["todayRequestsValue"], 2); - assert_eq!(body["data"]["cards"]["traffic"]["yesterdayRequests"], "1"); + assert_eq!(body["data"]["cards"]["traffic"]["totalRequests"], "2"); + assert_eq!( + body["data"]["cards"]["traffic"]["yesterdayRequestsValue"], + 1 + ); assert_eq!(body["data"]["cards"]["tokens"]["todayTokens"], "30"); assert_eq!(body["data"]["cards"]["tokens"]["todayTokensValue"], 30); - assert_eq!(body["data"]["cards"]["tokens"]["yesterdayTokens"], "30"); + assert_eq!(body["data"]["cards"]["tokens"]["totalTokens"], "30"); + assert_eq!(body["data"]["cards"]["tokens"]["yesterdayTokensValue"], 30); + assert_eq!(body["data"]["cards"]["cache"]["totalCachedTokens"], "0"); assert_eq!( body["data"]["cards"]["cache"]["firstTokenLatencyMs"], "300 ms" ); - assert_eq!( - body["data"]["cards"]["cache"]["completionLatencyMs"], - "1.50 s" - ); } #[tokio::test] @@ -167,6 +167,38 @@ async fn dashboard_trend_should_bucket_usage_by_china_hour() { assert_eq!(point["tokensValue"], 10); } +#[tokio::test] +async fn dashboard_trend_should_only_include_china_today() { + let (app, store, _pool, _dir) = dashboard_test_app( + "dashboard-trend-china-today.sqlite", + crate::support::fingerprint::test_fingerprint(), + ) + .await; + let now = Utc::now(); + let today_start = china_day_start(now); + store + .append(&usage_record_with_tokens( + today_start - Duration::hours(1), + 99, + )) + .await + .unwrap(); + store + .append(&usage_record_with_tokens(now, 7)) + .await + .unwrap(); + + let body = dashboard_trend(app, "usage").await; + let points = body["data"]["points"].as_array().unwrap(); + let token_total = points + .iter() + .map(|point| point["tokensValue"].as_u64().unwrap()) + .sum::(); + + assert_eq!(points.first().unwrap()["time"], "00"); + assert_eq!(token_total, 7); +} + #[tokio::test] async fn dashboard_latency_trend_should_use_first_token_latency() { let (app, store, _pool, _dir) = dashboard_test_app( @@ -241,7 +273,7 @@ async fn dashboard_summary_should_return_requested_trend_kind() { #[tokio::test] async fn dashboard_summary_should_calculate_priority_cost_from_event_service_tier() { - let cost = dashboard_summary_total_cost_for_usage_record( + let cost = dashboard_summary_today_cost_for_usage_record( "dashboard-cost-priority.sqlite", "gpt-5.5", json!({ @@ -255,12 +287,12 @@ async fn dashboard_summary_should_calculate_priority_cost_from_event_service_tie ) .await; - assert_f64_eq(cost, 8.75); + assert_eq!(cost, "$8.75"); } #[tokio::test] async fn dashboard_summary_should_charge_cached_input_at_cache_read_price() { - let cost = dashboard_summary_total_cost_for_usage_record( + let cost = dashboard_summary_today_cost_for_usage_record( "dashboard-cost-cached.sqlite", "gpt-5.5", json!({ @@ -273,12 +305,12 @@ async fn dashboard_summary_should_charge_cached_input_at_cache_read_price() { ) .await; - assert_f64_eq(cost, 0.03275); + assert_eq!(cost, "$0.03"); } #[tokio::test] async fn dashboard_summary_should_apply_long_context_prices() { - let cost = dashboard_summary_total_cost_for_usage_record( + let cost = dashboard_summary_today_cost_for_usage_record( "dashboard-cost-long-context.sqlite", "gpt-5.5", json!({ @@ -291,12 +323,12 @@ async fn dashboard_summary_should_apply_long_context_prices() { ) .await; - assert_f64_eq(cost, 3.045); + assert_eq!(cost, "$3.04"); } #[tokio::test] async fn dashboard_summary_should_normalize_codex_model_names_for_cost() { - let cost = dashboard_summary_total_cost_for_usage_record( + let cost = dashboard_summary_today_cost_for_usage_record( "dashboard-cost-codex-model.sqlite", "codex-auto-review", json!({ @@ -309,7 +341,7 @@ async fn dashboard_summary_should_normalize_codex_model_names_for_cost() { ) .await; - assert_f64_eq(cost, 1.75); + assert_eq!(cost, "$1.75"); } #[tokio::test] @@ -323,62 +355,106 @@ async fn dashboard_summary_should_not_price_usage_summary_without_model_dimensio let body = dashboard_summary(app).await; - assert_f64_eq( - body["data"]["cards"]["tokens"]["totalCostUsdValue"] - .as_f64() - .unwrap(), - 0.0, - ); + assert_eq!(body["data"]["cards"]["tokens"]["todayCostUsd"], "$0.00"); } #[tokio::test] async fn dashboard_summary_should_return_backend_formatted_time_fields() { - let (app, store, pool, _dir) = dashboard_test_app( + let (app, store, _pool, _dir) = dashboard_test_app( "dashboard-backend-formatted-time.sqlite", crate::support::fingerprint::test_fingerprint(), ) .await; + let record_at = Utc::now() - Duration::seconds(1); store - .append(&usage_record_with_tokens( - "2026-06-18T12:34:56Z".parse().unwrap(), - 10, - )) + .append(&usage_record_with_tokens(record_at, 10)) .await .unwrap(); - seed_usage_summary_with_last_used(&pool, 1, 1, "2000-01-01T00:00:00Z").await; let body = dashboard_summary(app).await; assert_eq!( body["data"]["usageRecords"][0]["createdAtDisplay"], - "2026-06-18 20:34:56" + china_datetime(&record_at) ); - assert_eq!(body["data"]["accountUsage"][0]["lastUsed"], "2000-01-01"); + assert!(body["data"]["usageRecords"][0].get("metadata").is_none()); +} + +#[tokio::test] +async fn dashboard_summary_should_only_return_today_account_usage_and_usage_records() { + let (app, store, pool, _dir) = dashboard_test_app( + "dashboard-summary-today-records.sqlite", + crate::support::fingerprint::test_fingerprint(), + ) + .await; + seed_dashboard_account(&pool, "acct_today", "today@example.com").await; + seed_dashboard_account(&pool, "acct_yesterday", "yesterday@example.com").await; + let now = Utc::now(); + let today_start = china_day_start(now); + let today_record_at = if now - today_start > Duration::minutes(1) { + today_start + Duration::minutes(1) + } else { + now - Duration::seconds(1) + }; + let yesterday_record_at = today_start - Duration::minutes(1); + store + .append(&usage_record_with_account_tokens( + yesterday_record_at, + "acct_yesterday", + 90, + )) + .await + .unwrap(); + store + .append(&usage_record_with_account_tokens( + today_record_at, + "acct_today", + 7, + )) + .await + .unwrap(); + + let body = dashboard_summary(app).await; + + assert_eq!(body["data"]["usageRecords"].as_array().unwrap().len(), 1); + assert_eq!( + body["data"]["usageRecords"][0]["accountEmail"], + "today@example.com" + ); + assert_eq!(body["data"]["accountUsage"].as_array().unwrap().len(), 1); + assert_eq!( + body["data"]["accountUsage"][0]["email"], + "today@example.com" + ); + assert_eq!(body["data"]["accountUsage"][0]["tokens"], "7"); } #[tokio::test] async fn dashboard_summary_should_order_account_usage_by_recent_usage() { - let (app, _store, pool, _dir) = dashboard_test_app( + let (app, store, pool, _dir) = dashboard_test_app( "dashboard-account-usage-recent-order.sqlite", crate::support::fingerprint::test_fingerprint(), ) .await; - seed_dashboard_account_usage( - &pool, - "acct_old_heavy", - "old-heavy@example.com", - 2_600_000, - "2026-06-29T09:00:00Z", - ) - .await; - seed_dashboard_account_usage( - &pool, - "acct_recent_light", - "recent-light@example.com", - 660_000, - "2026-06-30T08:00:00Z", - ) - .await; + seed_dashboard_account(&pool, "acct_old_heavy", "old-heavy@example.com").await; + seed_dashboard_account(&pool, "acct_recent_light", "recent-light@example.com").await; + let now = Utc::now(); + store + .append(&usage_record_with_account_tokens( + now - Duration::minutes(2), + "acct_old_heavy", + 2_600_000, + )) + .await + .unwrap(); + store + .append(&usage_record_with_account_tokens( + now - Duration::minutes(1), + "acct_recent_light", + 660_000, + )) + .await + .unwrap(); let body = dashboard_summary(app).await; @@ -386,13 +462,117 @@ async fn dashboard_summary_should_order_account_usage_by_recent_usage() { body["data"]["accountUsage"][0]["email"], "recent-light@example.com" ); - assert_eq!(body["data"]["accountUsage"][0]["requests"], "660K"); + assert_eq!(body["data"]["accountUsage"][0]["id"], "acct_recent_light"); + assert_eq!(body["data"]["accountUsage"][0]["planType"], "K12"); + assert_eq!(body["data"]["accountUsage"][0]["tokens"], "660K"); assert_eq!( body["data"]["accountUsage"][1]["email"], "old-heavy@example.com" ); } +#[tokio::test] +async fn dashboard_summary_should_use_five_hour_quota_window_for_account_usage() { + let (app, store, pool, _dir) = dashboard_test_app( + "dashboard-account-usage-five-hour-quota.sqlite", + crate::support::fingerprint::test_fingerprint(), + ) + .await; + seed_dashboard_account(&pool, "acct_quota_five_hour", "quota-five-hour@example.com").await; + store + .append(&usage_record_with_account_tokens( + Utc::now() - Duration::seconds(1), + "acct_quota_five_hour", + 1, + )) + .await + .unwrap(); + set_account_quota_json( + &pool, + "acct_quota_five_hour", + json!({ + "monthly_limit": { + "used_percent": 92.0, + "window_minutes": 43_200 + }, + "snapshots": [ + { + "source": "core", + "primary": { + "used_percent": 31.0, + "window_minutes": 300 + }, + "secondary": { + "used_percent": 88.0, + "window_minutes": 10_080 + } + } + ] + }), + ) + .await; + + let body = dashboard_summary(app).await; + + assert_f64_eq( + body["data"]["accountUsage"][0]["quotaUsedPercent"] + .as_f64() + .unwrap(), + 31.0, + ); +} + +#[tokio::test] +async fn dashboard_summary_should_fall_back_to_weekly_quota_window_when_five_hour_missing() { + let (app, store, pool, _dir) = dashboard_test_app( + "dashboard-account-usage-weekly-quota.sqlite", + crate::support::fingerprint::test_fingerprint(), + ) + .await; + seed_dashboard_account(&pool, "acct_quota_weekly", "quota-weekly@example.com").await; + store + .append(&usage_record_with_account_tokens( + Utc::now() - Duration::seconds(1), + "acct_quota_weekly", + 1, + )) + .await + .unwrap(); + set_account_quota_json( + &pool, + "acct_quota_weekly", + json!({ + "monthly_limit": { + "used_percent": 92.0, + "window_minutes": 43_200 + }, + "snapshots": [ + { + "source": "experimental", + "primary": { + "used_percent": 99.0, + "window_minutes": 120 + }, + "secondary": { + "used_percent": 47.0, + "window_minutes": 10_080 + } + } + ] + }), + ) + .await; + + let body = dashboard_summary(app).await; + + assert_f64_eq( + body["data"]["accountUsage"][0]["quotaUsedPercent"] + .as_f64() + .unwrap(), + 47.0, + ); +} + #[tokio::test] async fn dashboard_summary_should_return_today_health_timeline() { let (app, store, _pool, _dir) = dashboard_test_app( @@ -424,11 +604,11 @@ async fn dashboard_summary_should_return_today_health_timeline() { assert_eq!(points.matches('4').count(), 1); } -async fn dashboard_summary_total_cost_for_usage_record( +async fn dashboard_summary_today_cost_for_usage_record( db_name: &str, model: &str, metadata: Value, -) -> f64 { +) -> String { let (app, store, _pool, _dir) = dashboard_test_app(db_name, crate::support::fingerprint::test_fingerprint()).await; store @@ -437,9 +617,10 @@ async fn dashboard_summary_total_cost_for_usage_record( .unwrap(); let body = dashboard_summary(app).await; - body["data"]["cards"]["tokens"]["totalCostUsdValue"] - .as_f64() + body["data"]["cards"]["tokens"]["todayCostUsd"] + .as_str() .unwrap() + .to_string() } async fn dashboard_test_app( @@ -533,9 +714,10 @@ async fn seed_usage_summary_with_last_used( last_used_at: &str, ) { sqlx::query( - "insert into accounts (id, access_token, status, added_at, updated_at) values (?, ?, ?, ?, ?)", + "insert into accounts (id, email, access_token, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)", ) .bind("acct_usage") + .bind("acct-usage@example.com") .bind("cipher") .bind("active") .bind("2026-06-18T00:00:00Z") @@ -557,18 +739,14 @@ async fn seed_usage_summary_with_last_used( .unwrap(); } -async fn seed_dashboard_account_usage( - pool: &SqlitePool, - account_id: &str, - email: &str, - request_count: i64, - last_used_at: &str, -) { +async fn seed_dashboard_account(pool: &SqlitePool, account_id: &str, email: &str) { sqlx::query( - "insert into accounts (id, email, access_token, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)", + "insert into accounts (id, email, chatgpt_account_id, plan_type, access_token, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(account_id) .bind(email) + .bind(format!("upstream_{account_id}")) + .bind("K12") .bind("cipher") .bind("active") .bind("2026-06-18T00:00:00Z") @@ -576,18 +754,16 @@ async fn seed_dashboard_account_usage( .execute(pool) .await .unwrap(); - sqlx::query( - "insert into account_usage (account_id, request_count, input_tokens, output_tokens, total_tokens, last_used_at) values (?, ?, ?, ?, ?, ?)", - ) - .bind(account_id) - .bind(request_count) - .bind(0_i64) - .bind(0_i64) - .bind(0_i64) - .bind(last_used_at) - .execute(pool) - .await - .unwrap(); +} + +async fn set_account_quota_json(pool: &SqlitePool, account_id: &str, quota_json: Value) { + sqlx::query("update accounts set quota_json = ?, quota_fetched_at = ? where id = ?") + .bind(quota_json.to_string()) + .bind("2026-06-30T08:01:00Z") + .bind(account_id) + .execute(pool) + .await + .unwrap(); } fn service_status_value<'a>(body: &'a Value, label: &str) -> &'a str { @@ -614,6 +790,16 @@ fn usage_record_with_tokens(created_at: chrono::DateTime, total_tokens: u64 ) } +fn usage_record_with_account_tokens( + created_at: chrono::DateTime, + account_id: &str, + total_tokens: u64, +) -> UsageRecord { + let mut record = usage_record_with_tokens(created_at, total_tokens); + record.account_id = Some(account_id.to_string()); + record +} + fn usage_record(created_at: chrono::DateTime, model: &str, metadata: Value) -> UsageRecord { let mut record = UsageRecord::new("v1.response", UsageRecordLevel::Info, "completed"); record.model = Some(model.to_string()); diff --git a/frontend/src/views/accounts/components/AccountIdentityCell.vue b/frontend/src/views/accounts/components/AccountIdentityCell.vue index 18e089a3c..c43f53e93 100644 --- a/frontend/src/views/accounts/components/AccountIdentityCell.vue +++ b/frontend/src/views/accounts/components/AccountIdentityCell.vue @@ -4,9 +4,15 @@ import { computed } from 'vue' import AccountPlanBadge from './AccountPlanBadge.vue' +type AccountIdentity = { + id?: string | number | null + email: string + planType?: string | null +} + const props = withDefaults( defineProps<{ - account: any + account: AccountIdentity size?: 'md' | 'lg' showPlan?: boolean }>(), @@ -16,24 +22,11 @@ const props = withDefaults( }, ) -const displayTitle = computed( - () => - props.account.label?.trim() || - props.account.email || - props.account.accountId || - props.account.id, -) +const emailText = computed(() => props.account.email.trim()) -const secondaryText = computed(() => { - const title = displayTitle.value - const secondary = [ - props.account.email, - props.account.accountId, - props.account.userId, - props.account.id, - ].find((value) => value && value !== title) - return secondary || props.account.id -}) +const displayTitle = computed(() => emailText.value.split('@')[0]) + +const secondaryText = emailText const initial = computed(() => displayTitle.value.slice(0, 1).toUpperCase()) diff --git a/frontend/src/views/accounts/index.vue b/frontend/src/views/accounts/index.vue index 28525bc11..01607f78c 100644 --- a/frontend/src/views/accounts/index.vue +++ b/frontend/src/views/accounts/index.vue @@ -156,13 +156,13 @@ bindAccountLoader(loadAccounts)
- {{ selectedIds.size > 0 ? '导出选中' : '导出' }} + 导出选中 ({{ selectedIds.size }}) diff --git a/frontend/src/views/api-keys/components/ApiKeyIdentityCell.vue b/frontend/src/views/api-keys/components/ApiKeyIdentityCell.vue index f3707182e..ea140ff29 100644 --- a/frontend/src/views/api-keys/components/ApiKeyIdentityCell.vue +++ b/frontend/src/views/api-keys/components/ApiKeyIdentityCell.vue @@ -1,24 +1,7 @@ diff --git a/frontend/src/views/api-keys/composables/useApiKeyMutations.ts b/frontend/src/views/api-keys/composables/useApiKeyMutations.ts index c213dfaf8..9d8125974 100644 --- a/frontend/src/views/api-keys/composables/useApiKeyMutations.ts +++ b/frontend/src/views/api-keys/composables/useApiKeyMutations.ts @@ -16,18 +16,15 @@ export function useApiKeyMutations(selectedIds: Ref>) { const showSingleDeleteModal = ref(false) const showKeyModal = ref(false) const createdKey = ref('') - const editingLabel = ref<{ id: string; value: string } | null>(null) const pendingDeleteKey = ref(null) const creatingKeyAction = useAsyncAction() const deletingKeyAction = useAsyncAction() const batchDeletingAction = useAsyncAction() const updatingStatusKeys = useIdSet() - const savingLabelKeys = useIdSet() const creatingKey = creatingKeyAction.loading const deletingKey = deletingKeyAction.loading const batchDeleting = batchDeletingAction.loading const updatingStatusKeyIds = updatingStatusKeys.ids - const savingLabelKeyIds = savingLabelKeys.ids const createForm = ref({ name: '', @@ -124,40 +121,6 @@ export function useApiKeyMutations(selectedIds: Ref>) { }) } - async function handleUpdateLabel(keyId: string, label: string) { - await savingLabelKeys.run(keyId, async () => { - try { - await updateApiKey({ id: keyId, label: label || null }) - editingLabel.value = null - await loadApiKeys() - toast.success('标签已更新') - } catch (error: any) { - toast.error(error.message || '标签更新失败') - } - }) - } - - function startEditLabel(key: any) { - editingLabel.value = { id: key.id, value: key.label || '' } - } - - function cancelEditLabel() { - editingLabel.value = null - } - - function currentEditingLabelValue() { - return editingLabel.value?.value ?? '' - } - - function submitEditingLabel(keyId: string) { - void handleUpdateLabel(keyId, currentEditingLabelValue()) - } - - function updateEditingLabelValue(value: string) { - if (!editingLabel.value) return - editingLabel.value.value = value - } - async function copyToClipboard(text: string) { if (!text) { toast.error('复制失败') @@ -188,24 +151,17 @@ export function useApiKeyMutations(selectedIds: Ref>) { showSingleDeleteModal, showKeyModal, createdKey, - editingLabel, pendingDeleteKey, creatingKey, deletingKey, batchDeleting, updatingStatusKeyIds, - savingLabelKeyIds, createForm, handleCreate, requestDeleteKey, handleDelete, handleBatchDelete, handleToggleStatus, - startEditLabel, - cancelEditLabel, - currentEditingLabelValue, - submitEditingLabel, - updateEditingLabelValue, copyToClipboard, maskKey, } diff --git a/frontend/src/views/api-keys/index.vue b/frontend/src/views/api-keys/index.vue index bc11fa599..3b951337d 100644 --- a/frontend/src/views/api-keys/index.vue +++ b/frontend/src/views/api-keys/index.vue @@ -30,24 +30,17 @@ const { showSingleDeleteModal, showKeyModal, createdKey, - editingLabel, pendingDeleteKey, creatingKey, deletingKey, batchDeleting, updatingStatusKeyIds, - savingLabelKeyIds, createForm, handleCreate, requestDeleteKey, handleDelete, handleBatchDelete, handleToggleStatus, - startEditLabel, - cancelEditLabel, - currentEditingLabelValue, - submitEditingLabel, - updateEditingLabelValue, copyToClipboard, maskKey, } = useApiKeyMutations(selectedIds) @@ -158,16 +151,7 @@ function importToCcs(apiKey: any) {