fix: include account quota stats

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-06-25 13:32:46 +08:00
co-authored by Codex
parent 192f182f36
commit 745b77ad35
8 changed files with 807 additions and 46 deletions
+448 -4
View File
@@ -7,23 +7,36 @@ use axum::{
response::IntoResponse,
Extension, Json,
};
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
admin::accounts::service::{AdminAccountError, AdminAccountMetadata},
admin::auth::session::require_admin_session,
admin::response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse},
admin::{
accounts::service::{AdminAccountError, AdminAccountMetadata},
monitoring::{
billing,
event_store::AdminLogFilter,
events::{EventLevel, EventLog},
service::AdminUsageRecord,
},
},
http::middleware::request_id::RequestId,
infra::{
json::{clamp_limit, clamp_page, Page},
time::{china_datetime, china_rfc3339, china_rfc3339_str},
time::{china_datetime, china_relative_time, china_rfc3339, china_rfc3339_str},
},
runtime::state::AppState,
upstream::accounts::store::StoredAccount,
};
const ACCOUNT_STATS_PAGE_LIMIT: u32 = 200;
// ============================================================================
// Query / Request types
// ============================================================================
@@ -105,10 +118,23 @@ pub struct AdminAccountData {
pub added_at_display: String,
pub updated_at: String,
pub updated_at_display: String,
pub quota: AdminAccountQuotaData,
pub usage: AdminAccountUsageData,
}
impl From<AdminAccountMetadata> for AdminAccountData {
fn from(a: AdminAccountMetadata) -> Self {
Self::from_parts(a, None, None, None)
}
}
impl AdminAccountData {
fn from_parts(
a: AdminAccountMetadata,
usage: Option<&AdminUsageRecord>,
quota: Option<AdminAccountQuotaData>,
model_top: Option<AdminAccountModelUsageData>,
) -> Self {
let access_token_expires_at = a.access_token_expires_at.as_ref().map(china_rfc3339);
let access_token_expires_at_display =
a.access_token_expires_at.as_ref().map(china_datetime);
@@ -126,10 +152,155 @@ impl From<AdminAccountMetadata> for AdminAccountData {
added_at_display: china_datetime(&a.added_at),
updated_at: china_rfc3339(&a.updated_at),
updated_at_display: china_datetime(&a.updated_at),
quota: quota.unwrap_or_default(),
usage: AdminAccountUsageData::from_usage(usage, model_top),
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAccountQuotaData {
pub used_percent: Option<f64>,
pub used_percent_display: String,
pub reset_at_display: String,
pub refreshed_at_display: String,
pub window_used_display: String,
}
impl Default for AdminAccountQuotaData {
fn default() -> Self {
Self {
used_percent: None,
used_percent_display: "-".to_string(),
reset_at_display: "-".to_string(),
refreshed_at_display: "-".to_string(),
window_used_display: "-".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAccountUsageData {
pub request_count: i64,
pub request_count_display: String,
pub empty_response_count: i64,
pub input_tokens: i64,
pub input_tokens_display: String,
pub output_tokens: i64,
pub output_tokens_display: String,
pub cached_tokens: i64,
pub cached_tokens_display: String,
pub reasoning_tokens: i64,
pub total_tokens: i64,
pub total_tokens_display: String,
pub image_input_tokens: i64,
pub image_output_tokens: i64,
pub image_tokens_display: String,
pub image_request_count: i64,
pub image_request_failed_count: i64,
pub created_tokens: i64,
pub created_tokens_display: String,
pub read_tokens: i64,
pub read_tokens_display: String,
pub last_used_at: Option<String>,
pub last_used_at_display: String,
pub model_top: Option<AdminAccountModelUsageData>,
}
impl AdminAccountUsageData {
fn from_usage(
usage: Option<&AdminUsageRecord>,
model_top: Option<AdminAccountModelUsageData>,
) -> Self {
let request_count = usage.map_or(0, |usage| usage.request_count);
let empty_response_count = usage.map_or(0, |usage| usage.empty_response_count);
let input_tokens = usage.map_or(0, |usage| usage.input_tokens);
let output_tokens = usage.map_or(0, |usage| usage.output_tokens);
let cached_tokens = usage.map_or(0, |usage| usage.cached_tokens);
let reasoning_tokens = usage.map_or(0, |usage| usage.reasoning_tokens);
let total_tokens = usage.map_or(0, |usage| usage.total_tokens);
let image_input_tokens = usage.map_or(0, |usage| usage.image_input_tokens);
let image_output_tokens = usage.map_or(0, |usage| usage.image_output_tokens);
let image_request_count = usage.map_or(0, |usage| usage.image_request_count);
let image_request_failed_count = usage.map_or(0, |usage| usage.image_request_failed_count);
let created_tokens = input_tokens.saturating_sub(cached_tokens);
let read_tokens = cached_tokens;
let last_used_at = usage.and_then(|usage| usage.last_used_at);
Self {
request_count,
request_count_display: format_count(nonnegative_i64_to_u64(request_count)),
empty_response_count,
input_tokens,
input_tokens_display: format_tokens(nonnegative_i64_to_u64(input_tokens)),
output_tokens,
output_tokens_display: format_tokens(nonnegative_i64_to_u64(output_tokens)),
cached_tokens,
cached_tokens_display: format_tokens(nonnegative_i64_to_u64(cached_tokens)),
reasoning_tokens,
total_tokens,
total_tokens_display: format_tokens(nonnegative_i64_to_u64(total_tokens)),
image_input_tokens,
image_output_tokens,
image_tokens_display: format_tokens(nonnegative_i64_to_u64(
image_input_tokens + image_output_tokens,
)),
image_request_count,
image_request_failed_count,
created_tokens,
created_tokens_display: format_tokens(nonnegative_i64_to_u64(created_tokens)),
read_tokens,
read_tokens_display: format_tokens(nonnegative_i64_to_u64(read_tokens)),
last_used_at: last_used_at.map(|value| china_rfc3339(&value)),
last_used_at_display: china_relative_time(last_used_at, Utc::now()),
model_top,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAccountModelUsageData {
pub model: String,
pub request_count: u64,
pub request_count_display: String,
pub success_rate: f64,
pub success_rate_display: String,
pub input_tokens: u64,
pub input_tokens_display: String,
pub output_tokens: u64,
pub output_tokens_display: String,
pub cached_tokens: u64,
pub cached_tokens_display: String,
pub total_tokens: u64,
pub total_tokens_display: String,
pub total_cost_usd: f64,
pub total_cost_usd_display: String,
pub last_used_at: Option<String>,
pub last_used_at_display: String,
}
#[derive(Debug, Clone, Default)]
struct AccountListStats {
usage_by_account: HashMap<String, AdminUsageRecord>,
quota_by_account: HashMap<String, AdminAccountQuotaData>,
model_top_by_account: HashMap<String, AdminAccountModelUsageData>,
}
impl AccountListStats {
fn data_for(&self, account: AdminAccountMetadata) -> AdminAccountData {
let account_id = account.id.clone();
AdminAccountData::from_parts(
account,
self.usage_by_account.get(&account_id),
self.quota_by_account.get(&account_id).cloned(),
self.model_top_by_account.get(&account_id).cloned(),
)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchDeleteAccountsData {
@@ -246,6 +417,7 @@ pub async fn accounts(
require_admin_session(&state, &headers, &request_id).await?;
let limit = clamp_limit(params.page_size.or(params.limit).unwrap_or(50));
let use_numbered_page = params.page.is_some() || params.page_size.is_some();
let stats = account_list_stats(&state).await;
if use_numbered_page {
return match state
@@ -256,7 +428,11 @@ pub async fn accounts(
{
Ok(page) => {
let page = crate::infra::json::NumberedPage {
items: page.items.into_iter().map(AdminAccountData::from).collect(),
items: page
.items
.into_iter()
.map(|item| stats.data_for(item))
.collect(),
total: page.total,
page: page.page,
page_size: page.page_size,
@@ -278,7 +454,11 @@ pub async fn accounts(
{
Ok(page) => {
let page = Page {
items: page.items.into_iter().map(AdminAccountData::from).collect(),
items: page
.items
.into_iter()
.map(|item| stats.data_for(item))
.collect(),
next_cursor: page.next_cursor,
};
Ok(AdminResponse::new(
@@ -667,6 +847,270 @@ pub async fn update_account(
}
}
async fn account_list_stats(state: &AppState) -> AccountListStats {
let usage_records = list_all_usage_records(state).await;
let quota_snapshots = state
.services
.background_tasks
.accounts
.list_quota_snapshots()
.await
.unwrap_or_default();
let logs = list_all_event_logs(state).await;
AccountListStats {
usage_by_account: usage_records
.into_iter()
.map(|usage| (usage.account_id.clone(), usage))
.collect(),
quota_by_account: quota_snapshots
.into_iter()
.map(|snapshot| {
(
snapshot.account_id,
quota_data(&snapshot.quota_json, snapshot.quota_fetched_at),
)
})
.collect(),
model_top_by_account: model_top_by_account(&logs),
}
}
async fn list_all_usage_records(state: &AppState) -> Vec<AdminUsageRecord> {
let mut cursor = None;
let mut records = Vec::new();
loop {
let Ok(page) = state
.services
.usage
.list(cursor, ACCOUNT_STATS_PAGE_LIMIT)
.await
else {
return Vec::new();
};
records.extend(page.items);
let Some(next_cursor) = page.next_cursor else {
return records;
};
cursor = Some(next_cursor);
}
}
async fn list_all_event_logs(state: &AppState) -> Vec<EventLog> {
let mut cursor = None;
let mut logs = Vec::new();
loop {
let Ok(page) = state
.services
.logs
.list(cursor, ACCOUNT_STATS_PAGE_LIMIT, AdminLogFilter::default())
.await
else {
return Vec::new();
};
logs.extend(page.items);
let Some(next_cursor) = page.next_cursor else {
return logs;
};
cursor = Some(next_cursor);
}
}
#[derive(Debug, Clone, Default)]
struct ModelUsageAggregate {
model: String,
request_count: u64,
error_count: u64,
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
last_used_at: Option<DateTime<Utc>>,
}
fn model_top_by_account(logs: &[EventLog]) -> HashMap<String, AdminAccountModelUsageData> {
let mut by_account_model = HashMap::<(String, String), ModelUsageAggregate>::new();
for log in logs {
let Some(account_id) = log
.account_id
.as_ref()
.filter(|value| !value.trim().is_empty())
else {
continue;
};
let Some(model) = log.model.as_ref().filter(|value| !value.trim().is_empty()) else {
continue;
};
let aggregate = by_account_model
.entry((account_id.clone(), model.clone()))
.or_insert_with(|| ModelUsageAggregate {
model: model.clone(),
..ModelUsageAggregate::default()
});
aggregate.request_count += 1;
if log.level == EventLevel::Error || log.status_code.is_some_and(|status| status >= 400) {
aggregate.error_count += 1;
}
aggregate.input_tokens += metadata_usage_number(&log.metadata, "inputTokens");
aggregate.output_tokens += metadata_usage_number(&log.metadata, "outputTokens");
aggregate.cached_tokens += metadata_usage_number(&log.metadata, "cachedTokens");
aggregate.last_used_at = Some(
aggregate
.last_used_at
.map_or(log.created_at, |last| last.max(log.created_at)),
);
}
let mut by_account = HashMap::<String, ModelUsageAggregate>::new();
for ((account_id, _), aggregate) in by_account_model {
let replace = by_account
.get(&account_id)
.is_none_or(|current| aggregate.request_count > current.request_count);
if replace {
by_account.insert(account_id, aggregate);
}
}
by_account
.into_iter()
.map(|(account_id, aggregate)| (account_id, model_usage_data(aggregate)))
.collect()
}
fn model_usage_data(usage: ModelUsageAggregate) -> AdminAccountModelUsageData {
let total_tokens = usage.input_tokens + usage.output_tokens;
let success_rate = if usage.request_count > 0 {
((usage.request_count - usage.error_count) as f64 / usage.request_count as f64 * 1000.0)
.round()
/ 10.0
} else {
0.0
};
let total_cost_usd = billing::calculate_cost(
usage.input_tokens,
usage.output_tokens,
usage.cached_tokens,
&usage.model,
None,
);
AdminAccountModelUsageData {
model: usage.model,
request_count: usage.request_count,
request_count_display: format_count(usage.request_count),
success_rate,
success_rate_display: format_percent(success_rate),
input_tokens: usage.input_tokens,
input_tokens_display: format_tokens(usage.input_tokens),
output_tokens: usage.output_tokens,
output_tokens_display: format_tokens(usage.output_tokens),
cached_tokens: usage.cached_tokens,
cached_tokens_display: format_tokens(usage.cached_tokens),
total_tokens,
total_tokens_display: format_tokens(total_tokens),
total_cost_usd,
total_cost_usd_display: format_cost(total_cost_usd),
last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)),
last_used_at_display: china_relative_time(usage.last_used_at, Utc::now()),
}
}
fn quota_data(quota_json: &str, fetched_at: Option<DateTime<Utc>>) -> AdminAccountQuotaData {
let quota = serde_json::from_str::<Value>(quota_json).unwrap_or(Value::Null);
let used_percent = quota
.pointer("/rate_limit/used_percent")
.and_then(number_value)
.map(|value| value.clamp(0.0, 100.0));
let reset_at = quota
.pointer("/rate_limit/reset_at")
.and_then(Value::as_i64)
.and_then(|value| DateTime::<Utc>::from_timestamp(value, 0));
let window_seconds = quota
.pointer("/rate_limit/limit_window_seconds")
.and_then(Value::as_u64);
AdminAccountQuotaData {
used_percent,
used_percent_display: used_percent
.map(format_percent)
.unwrap_or_else(|| "-".to_string()),
reset_at_display: reset_at
.as_ref()
.map(china_datetime)
.unwrap_or_else(|| "-".to_string()),
refreshed_at_display: china_relative_time(fetched_at, Utc::now()),
window_used_display: quota_window_used_display(reset_at, window_seconds),
}
}
fn quota_window_used_display(
reset_at: Option<DateTime<Utc>>,
window_seconds: Option<u64>,
) -> String {
let (Some(reset_at), Some(window_seconds)) = (reset_at, window_seconds) else {
return "-".to_string();
};
let remaining = reset_at
.signed_duration_since(Utc::now())
.num_seconds()
.max(0) as u64;
let used = window_seconds.saturating_sub(remaining);
format!(
"{} / {}",
format_duration_days(used),
format_duration_days(window_seconds)
)
}
fn format_duration_days(seconds: u64) -> String {
let days = seconds as f64 / 86_400.0;
if days >= 1.0 {
format!("{days:.1}d")
} else {
format!("{:.1}h", seconds as f64 / 3_600.0)
}
}
fn metadata_usage_number(metadata: &Value, field: &str) -> u64 {
metadata
.get("usage")
.and_then(|usage| usage.get(field))
.or_else(|| metadata.get(field))
.and_then(Value::as_u64)
.unwrap_or(0)
}
fn number_value(value: &Value) -> Option<f64> {
value
.as_f64()
.or_else(|| value.as_str().and_then(|value| value.parse::<f64>().ok()))
}
fn nonnegative_i64_to_u64(value: i64) -> u64 {
u64::try_from(value).unwrap_or(0)
}
fn format_count(value: u64) -> String {
value.to_string()
}
fn format_tokens(value: u64) -> String {
if value >= 1_000_000 {
format!("{:.1}M", value as f64 / 1_000_000.0)
} else if value >= 1_000 {
format!("{:.1}K", value as f64 / 1_000.0)
} else {
value.to_string()
}
}
fn format_percent(value: f64) -> String {
format!("{value:.1}%")
}
fn format_cost(value: f64) -> String {
format!("${value:.2}")
}
// ============================================================================
// Error handling
// ============================================================================
+1 -1
View File
@@ -1,6 +1,6 @@
//! 管理面监控模块:日志、用量统计和 Dashboard 聚合。
mod billing;
pub(crate) mod billing;
pub mod dashboard;
pub mod diagnostics;
pub mod event_store;
+3 -1
View File
@@ -15,7 +15,7 @@ use crate::{
http::middleware::request_id::RequestId,
infra::{
json::{clamp_limit, clamp_page, Page},
time::china_rfc3339,
time::{china_relative_time, china_rfc3339},
},
runtime::state::AppState,
};
@@ -50,6 +50,7 @@ pub struct AdminUsageStatsData {
pub image_request_count: i64,
pub image_request_failed_count: i64,
pub last_used_at: Option<String>,
pub last_used_at_display: String,
}
/// 管理端账号用量汇总响应。
@@ -174,6 +175,7 @@ impl From<AdminUsageRecord> for AdminUsageStatsData {
image_output_tokens: usage.image_output_tokens,
image_request_count: usage.image_request_count,
image_request_failed_count: usage.image_request_failed_count,
last_used_at_display: china_relative_time(usage.last_used_at, chrono::Utc::now()),
last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)),
}
}
+75
View File
@@ -1,4 +1,5 @@
use super::*;
use codex_proxy_rs::admin::monitoring::events::{EventLevel, EventLog};
#[tokio::test]
async fn admin_accounts_list_should_not_decrypt_account_tokens() {
@@ -55,6 +56,80 @@ async fn admin_accounts_list_should_not_decrypt_account_tokens() {
);
}
#[tokio::test]
async fn admin_accounts_list_should_include_usage_quota_and_model_stats() {
let (app, _state, pool, _dir, _secret_box) =
admin_accounts_test_app("admin-accounts-stats.sqlite", 68).await;
seed_usage_account(
&pool,
"acct_stats",
"stats@example.com",
"stats",
"free",
41,
5,
3_500_000,
13_900,
3_400_000,
"2026-06-23T08:50:13Z",
)
.await;
sqlx::query("update accounts set quota_json = ?, quota_fetched_at = ? where id = ?")
.bind(
json!({
"rate_limit": {
"used_percent": 87.8,
"reset_at": 1782737460,
"limit_window_seconds": 604800
}
})
.to_string(),
)
.bind("2026-06-23T08:51:09Z")
.bind("acct_stats")
.execute(&pool)
.await
.unwrap();
let store = SqliteEventLogStore::new(pool.clone());
let mut log = EventLog::new("response", EventLevel::Info, "ok");
log.account_id = Some("acct_stats".to_string());
log.model = Some("gpt-5.5".to_string());
log.created_at = "2026-06-23T08:50:13Z".parse().unwrap();
log.metadata = json!({
"usage": {
"inputTokens": 3_500_000u64,
"outputTokens": 13_900u64,
"cachedTokens": 3_400_000u64
}
});
store.append(&log).await.unwrap();
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts?page=1&pageSize=10")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_accounts_stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
let item = &body["data"]["items"][0];
assert_eq!(item["usage"]["requestCount"], 41);
assert_eq!(item["quota"]["usedPercentDisplay"], "87.8%");
assert!(item["quota"]["windowUsedDisplay"]
.as_str()
.is_some_and(|value| value.contains(" / 7.0d")));
assert_eq!(item["usage"]["createdTokens"], 100_000);
assert_eq!(item["usage"]["readTokens"], 3_400_000);
assert_eq!(item["usage"]["modelTop"]["model"], "gpt-5.5");
}
#[tokio::test]
async fn admin_accounts_list_should_return_numbered_page_with_search_total() {
let (app, _state, pool, _dir, _secret_box) =
+57
View File
@@ -15,6 +15,63 @@ export interface Account {
addedAtDisplay: string
updatedAt: string
updatedAtDisplay: string
quota: AccountQuota
usage: AccountUsage
}
export interface AccountQuota {
usedPercent?: number | null
usedPercentDisplay: string
resetAtDisplay: string
refreshedAtDisplay: string
windowUsedDisplay: string
}
export interface AccountUsage {
requestCount: number
requestCountDisplay: string
emptyResponseCount: number
inputTokens: number
inputTokensDisplay: string
outputTokens: number
outputTokensDisplay: string
cachedTokens: number
cachedTokensDisplay: string
reasoningTokens: number
totalTokens: number
totalTokensDisplay: string
imageInputTokens: number
imageOutputTokens: number
imageTokensDisplay: string
imageRequestCount: number
imageRequestFailedCount: number
createdTokens: number
createdTokensDisplay: string
readTokens: number
readTokensDisplay: string
lastUsedAt?: string
lastUsedAtDisplay: string
modelTop?: AccountModelUsage | null
}
export interface AccountModelUsage {
model: string
requestCount: number
requestCountDisplay: string
successRate: number
successRateDisplay: string
inputTokens: number
inputTokensDisplay: string
outputTokens: number
outputTokensDisplay: string
cachedTokens: number
cachedTokensDisplay: string
totalTokens: number
totalTokensDisplay: string
totalCostUsd: number
totalCostUsdDisplay: string
lastUsedAt?: string
lastUsedAtDisplay: string
}
export interface AccountsQuery {
+1
View File
@@ -39,6 +39,7 @@ export interface AccountUsageStats {
imageRequestCount: number
imageRequestFailedCount: number
lastUsedAt?: string
lastUsedAtDisplay: string
}
export interface UsageStatsQuery {
+44 -29
View File
@@ -40,10 +40,12 @@ const props = withDefaults(
emptyText?: string
maxHeight?: string
pagination?: BaseTablePagination
expandedRowKeys?: Array<string | number>
}>(),
{
rowKey: 'id',
selectedRowKeys: () => [],
expandedRowKeys: () => [],
stripe: true,
loading: false,
emptyText: '暂无数据',
@@ -201,6 +203,10 @@ function isRowSelected(row: TableRow, index: number) {
return props.selectedRowKeys.includes(getRowKey(row, index))
}
function isRowExpanded(row: TableRow, index: number) {
return props.expandedRowKeys.includes(getRowKey(row, index))
}
function alignClass(column: BaseTableColumn) {
if (column.align === 'center') {
return 'text-center'
@@ -342,36 +348,45 @@ function paginationPageClass(page: number) {
</td>
</tr>
<tr
v-for="(row, index) in rows"
:key="getRowKey(row, index)"
:class="rowClass(row, index)"
role="row"
>
<td
v-for="(column, columnIndex) in computedColumns"
:key="column.key"
:class="[
bodyCellClass,
alignClass(column),
isLastColumn(columnIndex) ? 'rounded-r-lg pr-3' : undefined,
column.cellClass,
]"
role="cell"
<template v-for="(row, index) in rows" :key="getRowKey(row, index)">
<tr
:class="rowClass(row, index)"
role="row"
>
<div :class="cellContentClass(column)">
<slot
:name="column.key"
:row="row"
:value="cellValue(row, column.key)"
:index="index"
>
{{ cellValue(row, column.key) }}
</slot>
</div>
</td>
<td class="p-0" aria-hidden="true" />
</tr>
<td
v-for="(column, columnIndex) in computedColumns"
:key="column.key"
:class="[
bodyCellClass,
alignClass(column),
isLastColumn(columnIndex) ? 'rounded-r-lg pr-3' : undefined,
column.cellClass,
]"
role="cell"
>
<div :class="cellContentClass(column)">
<slot
:name="column.key"
:row="row"
:value="cellValue(row, column.key)"
:index="index"
>
{{ cellValue(row, column.key) }}
</slot>
</div>
</td>
<td class="p-0" aria-hidden="true" />
</tr>
<tr v-if="isRowExpanded(row, index)" role="row">
<td
:colspan="computedColumns.length + 1"
class="rounded-lg bg-(--cp-info-bg) p-0"
role="cell"
>
<slot name="expanded" :row="row" :index="index" />
</td>
</tr>
</template>
</tbody>
</table>
</BaseScrollbar>
+178 -11
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Plus, RefreshCw, Trash2, Download, Upload, Search } from '@lucide/vue'
import { ChevronDown, Plus, RefreshCw, Trash2, Download, Upload, Search } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
@@ -18,6 +18,7 @@ import {
batchDeleteAccounts,
createAccount,
deleteAccount,
getAccountQuota,
getAccounts,
refreshAccount,
} from '@/api'
@@ -29,12 +30,14 @@ const page = ref(1)
const pageSize = ref(20)
const searchQuery = ref('')
const selectedIds = ref<Set<string>>(new Set())
const expandedAccountIds = ref<Set<string>>(new Set())
const showCreateModal = ref(false)
const showDeleteModal = ref(false)
const showSingleDeleteModal = ref(false)
const pendingDeleteAccount = ref<Account | null>(null)
const refreshingList = ref(false)
const refreshingAccountIds = ref<Set<string>>(new Set())
const refreshingQuotaAccountIds = ref<Set<string>>(new Set())
const deletingAccount = ref(false)
const batchDeleting = ref(false)
let searchTimer: number | undefined
@@ -196,6 +199,23 @@ async function handleRefresh(accountId: string) {
}
}
async function handleRefreshQuota(accountId: string) {
if (refreshingQuotaAccountIds.value.has(accountId)) return
refreshingQuotaAccountIds.value = new Set(refreshingQuotaAccountIds.value).add(accountId)
try {
await withMinimumDuration(async () => {
await getAccountQuota(accountId)
await loadAccounts()
})
} catch (error) {
console.error('Failed to refresh account quota:', error)
} finally {
const next = new Set(refreshingQuotaAccountIds.value)
next.delete(accountId)
refreshingQuotaAccountIds.value = next
}
}
function statusTone(status: Account['status']) {
return statusTones[status]
}
@@ -212,6 +232,16 @@ function toggleSelection(accountId: string) {
}
}
function toggleExpanded(accountId: string) {
const next = new Set(expandedAccountIds.value)
if (next.has(accountId)) {
next.delete(accountId)
} else {
next.add(accountId)
}
expandedAccountIds.value = next
}
function toggleAll() {
if (allSelected.value) {
accounts.value.forEach((account) => selectedIds.value.delete(account.id))
@@ -231,6 +261,10 @@ function handlePageSizeChange(nextPageSize: number) {
void loadAccounts()
}
function quotaBarWidth(account: Account) {
return `${Math.max(0, Math.min(account.quota.usedPercent ?? 0, 100))}%`
}
onMounted(() => {
loadAccounts()
})
@@ -319,6 +353,7 @@ onBeforeUnmount(() => {
:columns="accountColumns"
:rows="filteredAccounts"
:selected-row-keys="selectedRowKeys"
:expanded-row-keys="[...expandedAccountIds]"
:pagination="accountPagination"
empty-text="暂无账号数据"
@page-change="handlePageChange"
@@ -335,12 +370,25 @@ onBeforeUnmount(() => {
</template>
<template #selection="{ row }">
<BaseCheckbox
:model-value="selectedIds.has(row.id)"
label="选择账号"
size="table"
@update:model-value="toggleSelection(row.id)"
/>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-(--cp-text-secondary) transition hover:bg-(--cp-default-bg-hover) hover:text-(--cp-text-primary)"
:title="expandedAccountIds.has(row.id) ? '收起统计' : '展开统计'"
@click.stop="toggleExpanded(row.id)"
>
<ChevronDown
class="size-3.5 transition-transform"
:class="expandedAccountIds.has(row.id) ? 'rotate-180' : '-rotate-90'"
/>
</button>
<BaseCheckbox
:model-value="selectedIds.has(row.id)"
label="选择账号"
size="table"
@update:model-value="toggleSelection(row.id)"
/>
</div>
</template>
<template #identity="{ row }">
@@ -370,12 +418,16 @@ onBeforeUnmount(() => {
</span>
</template>
<template #requests>
<span class="font-mono text-[14px] text-(--cp-text-secondary)"> — </span>
<template #requests="{ row }">
<span class="font-mono text-[14px] text-(--cp-text-primary)">
{{ row.usage.requestCountDisplay }}
</span>
</template>
<template #tokens>
<span class="font-mono text-[14px] text-(--cp-text-secondary)"> — </span>
<template #tokens="{ row }">
<span class="font-mono text-[14px] text-(--cp-text-primary)">
{{ row.usage.totalTokensDisplay }}
</span>
</template>
<template #updatedAt="{ row }">
@@ -412,6 +464,121 @@ onBeforeUnmount(() => {
</BaseIconButton>
</div>
</template>
<template #expanded="{ row }">
<div class="grid gap-3 p-4 lg:grid-cols-[1.05fr_2.45fr]">
<section class="rounded-lg bg-(--cp-bg-surface) p-4 shadow-(--cp-shadow-control)">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<h3 class="m-0 text-[14px] font-[760] text-(--cp-text-primary)">账号额度</h3>
<p class="m-0 mt-1 text-[12px] font-[620] text-(--cp-text-secondary)">
Codex 额度 · 套餐: {{ row.planType || 'Free' }} · 最近刷新:
{{ row.quota.refreshedAtDisplay }}
</p>
</div>
<BaseIconButton
variant="ghost"
size="sm"
title="刷新额度"
:loading="refreshingQuotaAccountIds.has(row.id)"
@click="handleRefreshQuota(row.id)"
>
<RefreshCw class="size-3.5" />
</BaseIconButton>
</div>
<div class="flex items-center justify-between text-[12px] font-[720]">
<span class="text-(--cp-text-secondary)">周限额</span>
<span class="text-(--cp-text-primary)">{{ row.quota.usedPercentDisplay }}</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-(--cp-bg-tertiary)">
<div
class="h-full rounded-full bg-(--cp-info)"
:style="{ width: quotaBarWidth(row) }"
/>
</div>
<div class="mt-3 flex justify-between text-[12px] font-[620] text-(--cp-text-secondary)">
<span>重置时间: {{ row.quota.resetAtDisplay }}</span>
<span>窗口已用: {{ row.quota.windowUsedDisplay }}</span>
</div>
</section>
<section
class="grid gap-4 rounded-lg bg-(--cp-bg-surface) p-4 shadow-(--cp-shadow-control) xl:grid-cols-[0.52fr_1.48fr]"
>
<div>
<h3 class="m-0 mb-3 text-[14px] font-[760] text-(--cp-text-primary)">
Token 结构
</h3>
<div class="grid gap-2">
<div class="flex items-center justify-between rounded-lg bg-green-50 px-3 py-2">
<span class="text-[12px] font-[700] text-green-700">输入 Tokens</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ row.usage.inputTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-amber-50 px-3 py-2">
<span class="text-[12px] font-[700] text-amber-700">输出 Tokens</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ row.usage.outputTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-cyan-50 px-3 py-2">
<span class="text-[12px] font-[700] text-cyan-700">缓存 Tokens</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ row.usage.cachedTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-sky-50 px-3 py-2">
<span class="text-[12px] font-[700] text-sky-700">创建</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ row.usage.createdTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-sky-50 px-3 py-2">
<span class="text-[12px] font-[700] text-sky-700">读取</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ row.usage.readTokensDisplay }}
</strong>
</div>
</div>
</div>
<div class="min-w-0 pt-4 shadow-[inset_0_1px_0_rgba(216,224,234,0.42)] xl:pt-0 xl:pl-4 xl:shadow-[inset_1px_0_0_rgba(216,224,234,0.42)]">
<div class="mb-3 flex items-center justify-between">
<h3 class="m-0 text-[14px] font-[760] text-(--cp-text-primary)">
模型使用 Top 1
</h3>
</div>
<div class="grid grid-cols-[1.2fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.4fr] gap-3 pb-2 text-[11px] font-[760] text-(--cp-text-muted) shadow-[inset_0_-1px_0_rgba(216,224,234,0.42)]">
<span>模型</span>
<span>调用</span>
<span>成功率</span>
<span>输入</span>
<span>输出</span>
<span>缓存</span>
<span>总TOKEN</span>
<span>总花费</span>
<span>最近请求时间</span>
</div>
<div class="grid grid-cols-[1.2fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.4fr] gap-3 pt-3 text-[12px] font-[650] text-(--cp-text-primary)">
<span class="truncate">{{ row.usage.modelTop?.model || '-' }}</span>
<span>{{ row.usage.modelTop?.requestCountDisplay || '-' }}</span>
<span class="text-amber-600">
{{ row.usage.modelTop?.successRateDisplay || '-' }}
</span>
<span>{{ row.usage.modelTop?.inputTokensDisplay || '-' }}</span>
<span>{{ row.usage.modelTop?.outputTokensDisplay || '-' }}</span>
<span>{{ row.usage.modelTop?.cachedTokensDisplay || '-' }}</span>
<span>{{ row.usage.modelTop?.totalTokensDisplay || '-' }}</span>
<span>{{ row.usage.modelTop?.totalCostUsdDisplay || '-' }}</span>
<span>{{ row.usage.modelTop?.lastUsedAtDisplay || '-' }}</span>
</div>
</div>
</section>
</div>
</template>
</BaseTable>
</BaseCard>