mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: refine dashboard usage reporting
Track account usage windows from the first use or reset cycle. Render today's request health timeline with availability levels and polish dashboard card layout. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -12,7 +12,7 @@ use axum::{
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::{QueryBuilder, Row, Sqlite};
|
||||
@@ -37,7 +37,7 @@ use crate::{
|
||||
format_cost, format_percent, format_plain_number, format_tokens, nonnegative_i64_to_u64,
|
||||
},
|
||||
json::{clamp_limit, clamp_page, total_pages, Page},
|
||||
time::{china_datetime, china_relative_time, china_rfc3339},
|
||||
time::{china_datetime, china_quarter_hour_start, china_relative_time, china_rfc3339},
|
||||
},
|
||||
runtime::state::AppState,
|
||||
upstream::accounts::model::AccountStatus,
|
||||
@@ -1029,10 +1029,11 @@ async fn list_current_window_model_usage(
|
||||
if index > 0 {
|
||||
builder.push(" or ");
|
||||
}
|
||||
let bucket_start = china_quarter_hour_start(*start);
|
||||
builder.push("(account_id = ");
|
||||
builder.push_bind(account_id);
|
||||
builder.push(" and bucket_start >= ");
|
||||
builder.push_bind(start.to_rfc3339());
|
||||
builder.push_bind(bucket_start.to_rfc3339());
|
||||
builder.push(" and bucket_start <= ");
|
||||
builder.push_bind(end.to_rfc3339());
|
||||
builder.push(")");
|
||||
@@ -1095,11 +1096,7 @@ fn current_usage_window(
|
||||
usage: &AdminUsageRecord,
|
||||
now: DateTime<Utc>,
|
||||
) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
|
||||
let start = usage.window_started_at.or_else(|| {
|
||||
let reset_at = usage.window_reset_at?;
|
||||
let seconds = i64::try_from(usage.limit_window_seconds?).ok()?;
|
||||
Some(reset_at - Duration::seconds(seconds))
|
||||
})?;
|
||||
let start = usage.window_started_at?;
|
||||
let end = usage.window_reset_at.unwrap_or(now);
|
||||
(start <= end).then_some((start, end))
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ use crate::{
|
||||
|
||||
const DASHBOARD_USAGE_RECORD_LIMIT: u32 = 1000;
|
||||
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
|
||||
const HEALTH_TIMELINE_SLOTS: i64 = 7 * 24 * 4;
|
||||
const DASHBOARD_TIME_BUCKET_SLOTS: i64 = 7 * 24 * 4;
|
||||
const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4;
|
||||
const HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD: u64 = 3;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -432,7 +434,7 @@ async fn dashboard_time_buckets(
|
||||
) -> Vec<AdminUsageTimeBucketRecord> {
|
||||
let current_slot = china_quarter_hour_start(now);
|
||||
let start = current_slot
|
||||
- Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (HEALTH_TIMELINE_SLOTS - 1));
|
||||
- Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (DASHBOARD_TIME_BUCKET_SLOTS - 1));
|
||||
state
|
||||
.services
|
||||
.usage
|
||||
@@ -530,8 +532,7 @@ fn dashboard_health_timeline_data_at(
|
||||
now: DateTime<Utc>,
|
||||
) -> DashboardHealthTimelineData {
|
||||
let current_slot = china_quarter_hour_start(now);
|
||||
let start = current_slot
|
||||
- Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (HEALTH_TIMELINE_SLOTS - 1));
|
||||
let start = china_day_start(now);
|
||||
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
|
||||
.map(|index| {
|
||||
let bucket_start = start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index);
|
||||
@@ -554,52 +555,121 @@ fn dashboard_health_timeline_data_at(
|
||||
|
||||
let requests = buckets
|
||||
.iter()
|
||||
.filter(|(bucket_start, _)| *bucket_start <= current_slot)
|
||||
.map(|(_, bucket)| bucket.requests)
|
||||
.sum::<u64>();
|
||||
let errors = buckets.iter().map(|(_, bucket)| bucket.errors).sum::<u64>();
|
||||
let errors = buckets
|
||||
.iter()
|
||||
.filter(|(bucket_start, _)| *bucket_start <= current_slot)
|
||||
.map(|(_, bucket)| bucket.errors)
|
||||
.sum::<u64>();
|
||||
let successes = requests.saturating_sub(errors);
|
||||
let reliability = if requests > 0 {
|
||||
((requests - errors) as f64 / requests as f64 * 1000.0).round() / 10.0
|
||||
(successes as f64 / requests as f64 * 1000.0).round() / 10.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let end = current_slot + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES);
|
||||
|
||||
DashboardHealthTimelineData {
|
||||
title: "请求健康时间线".to_string(),
|
||||
description: "最近 7 天请求可靠性".to_string(),
|
||||
range_display: format!("{} - {}", china_datetime(&start), china_datetime(&end)),
|
||||
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: "最早".to_string(),
|
||||
newest_label: "最新".to_string(),
|
||||
oldest_label: "00:00".to_string(),
|
||||
newest_label: "24:00".to_string(),
|
||||
points: buckets
|
||||
.into_iter()
|
||||
.map(|(_, bucket)| {
|
||||
.map(|(bucket_start, bucket)| {
|
||||
let success_rate = if bucket.requests > 0 {
|
||||
((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0)
|
||||
(bucket.requests.saturating_sub(bucket.errors) as f64 / bucket.requests as f64
|
||||
* 1000.0)
|
||||
.round()
|
||||
/ 10.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
health_tone(bucket, success_rate)
|
||||
health_tone(bucket, success_rate, bucket_start > current_slot)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn health_tone(bucket: UsageWindow, success_rate: f64) -> char {
|
||||
if bucket.requests == 0 {
|
||||
fn health_tone(bucket: UsageWindow, success_rate: f64, is_future: bool) -> char {
|
||||
let successes = bucket.requests.saturating_sub(bucket.errors);
|
||||
if is_future {
|
||||
'0'
|
||||
} else if bucket.errors == 0 {
|
||||
} else if bucket.requests == 0 {
|
||||
'1'
|
||||
} else if success_rate >= 90.0 {
|
||||
} else if successes == 0 {
|
||||
'2'
|
||||
} else {
|
||||
} else if success_rate < 90.0 {
|
||||
'3'
|
||||
} else if successes < HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD {
|
||||
'4'
|
||||
} else {
|
||||
'5'
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{health_tone, UsageWindow};
|
||||
|
||||
#[test]
|
||||
fn health_tone_should_classify_availability_levels() {
|
||||
assert_eq!(health_tone(UsageWindow::default(), 0.0, true), '0');
|
||||
assert_eq!(health_tone(UsageWindow::default(), 0.0, false), '1');
|
||||
assert_eq!(
|
||||
health_tone(
|
||||
UsageWindow {
|
||||
requests: 2,
|
||||
errors: 2,
|
||||
..UsageWindow::default()
|
||||
},
|
||||
0.0,
|
||||
false,
|
||||
),
|
||||
'2'
|
||||
);
|
||||
assert_eq!(
|
||||
health_tone(
|
||||
UsageWindow {
|
||||
requests: 10,
|
||||
errors: 2,
|
||||
..UsageWindow::default()
|
||||
},
|
||||
80.0,
|
||||
false,
|
||||
),
|
||||
'3'
|
||||
);
|
||||
assert_eq!(
|
||||
health_tone(
|
||||
UsageWindow {
|
||||
requests: 2,
|
||||
errors: 0,
|
||||
..UsageWindow::default()
|
||||
},
|
||||
100.0,
|
||||
false,
|
||||
),
|
||||
'4'
|
||||
);
|
||||
assert_eq!(
|
||||
health_tone(
|
||||
UsageWindow {
|
||||
requests: 3,
|
||||
errors: 0,
|
||||
..UsageWindow::default()
|
||||
},
|
||||
100.0,
|
||||
false,
|
||||
),
|
||||
'5'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -718,8 +718,8 @@ impl AccountPool {
|
||||
account.request_count = account.request_count.saturating_add(1);
|
||||
account.window_request_count = account.window_request_count.saturating_add(1);
|
||||
if account.window_started_at.is_none() {
|
||||
account.window_started_at = Some(now);
|
||||
if let Some(seconds) = account.limit_window_seconds {
|
||||
account.window_started_at = Some(now);
|
||||
let seconds = i64::try_from(seconds).unwrap_or(i64::MAX);
|
||||
account.window_reset_at = Some(now + Duration::seconds(seconds));
|
||||
}
|
||||
|
||||
@@ -293,8 +293,9 @@ insert into account_usage (
|
||||
window_image_output_tokens,
|
||||
window_image_request_count,
|
||||
window_image_request_failed_count,
|
||||
window_started_at,
|
||||
last_used_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(account_id) do update set
|
||||
request_count = request_count + excluded.request_count,
|
||||
empty_response_count = empty_response_count + excluded.empty_response_count,
|
||||
@@ -315,13 +316,8 @@ on conflict(account_id) do update set
|
||||
window_image_output_tokens = window_image_output_tokens + excluded.window_image_output_tokens,
|
||||
window_image_request_count = window_image_request_count + excluded.window_image_request_count,
|
||||
window_image_request_failed_count = window_image_request_failed_count + excluded.window_image_request_failed_count,
|
||||
window_started_at = case
|
||||
when account_usage.window_started_at is null
|
||||
and (account_usage.window_reset_at is not null or account_usage.limit_window_seconds is not null)
|
||||
then excluded.last_used_at
|
||||
else account_usage.window_started_at
|
||||
end,
|
||||
last_used_at = excluded.last_used_at";
|
||||
window_started_at = coalesce(account_usage.window_started_at, excluded.window_started_at),
|
||||
last_used_at = excluded.last_used_at";
|
||||
|
||||
const RECORD_MODEL_USAGE_SQL: &str = r"
|
||||
insert into account_model_usage (
|
||||
@@ -1293,6 +1289,7 @@ impl SqliteAccountStore {
|
||||
.bind(delta.window_image_request_count)
|
||||
.bind(delta.window_image_request_failed_count)
|
||||
.bind(&last_used_at)
|
||||
.bind(&last_used_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
|
||||
@@ -252,6 +252,140 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() {
|
||||
assert_eq!(item["usage"]["models"][1]["model"], "gpt-5");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_accounts_list_should_require_current_window_start_for_model_stats() {
|
||||
let (app, _state, pool, _dir) =
|
||||
admin_accounts_test_app("admin-accounts-model-window-start.sqlite", 69).await;
|
||||
sqlx::query("insert into accounts (id, email, access_token, status, added_at, updated_at) values (?, ?, ?, 'active', ?, ?)")
|
||||
.bind("acct_no_window_start")
|
||||
.bind("stats@example.com")
|
||||
.bind("access-token")
|
||||
.bind("2026-06-11T00:00:00Z")
|
||||
.bind("2026-06-11T00:00:00Z")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"insert into account_usage (
|
||||
account_id,
|
||||
request_count,
|
||||
window_request_count,
|
||||
window_input_tokens,
|
||||
window_output_tokens,
|
||||
window_cached_tokens,
|
||||
window_reset_at,
|
||||
limit_window_seconds,
|
||||
last_used_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("acct_no_window_start")
|
||||
.bind(3)
|
||||
.bind(3)
|
||||
.bind(120)
|
||||
.bind(40)
|
||||
.bind(20)
|
||||
.bind("2026-06-27T00:00:00Z")
|
||||
.bind(604_800)
|
||||
.bind("2026-06-23T08:50:13Z")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"insert into usage_time_buckets (bucket_start, account_id, model, request_count, input_tokens, output_tokens, cached_tokens, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("2026-06-23T08:45:00+00:00")
|
||||
.bind("acct_no_window_start")
|
||||
.bind("gpt-5.5")
|
||||
.bind(3)
|
||||
.bind(120)
|
||||
.bind(40)
|
||||
.bind(20)
|
||||
.bind("2026-06-23T08:45:00Z")
|
||||
.execute(&pool)
|
||||
.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_model_window_start")
|
||||
.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"]["models"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_accounts_list_should_include_bucket_containing_window_start_for_model_stats() {
|
||||
let (app, _state, pool, _dir) =
|
||||
admin_accounts_test_app("admin-accounts-model-window-bucket-start.sqlite", 70).await;
|
||||
seed_usage_account(
|
||||
&pool,
|
||||
UsageAccountSeed {
|
||||
id: "acct_bucket_start",
|
||||
email: "bucket@example.com",
|
||||
label: "bucket",
|
||||
plan_type: "free",
|
||||
request_count: 2,
|
||||
empty_response_count: 0,
|
||||
input_tokens: 120,
|
||||
output_tokens: 40,
|
||||
cached_tokens: 20,
|
||||
window_request_count: 2,
|
||||
window_input_tokens: 120,
|
||||
window_output_tokens: 40,
|
||||
window_cached_tokens: 20,
|
||||
window_started_at: "2026-06-29T12:56:15.098980168+00:00",
|
||||
window_reset_at: "2026-07-29T12:56:15.098980168+00:00",
|
||||
limit_window_seconds: 2_592_000,
|
||||
last_used_at: "2026-06-29T12:59:42.044220420+00:00",
|
||||
},
|
||||
)
|
||||
.await;
|
||||
sqlx::query(
|
||||
"insert into usage_time_buckets (bucket_start, account_id, model, request_count, input_tokens, output_tokens, cached_tokens, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("2026-06-29T12:45:00+00:00")
|
||||
.bind("acct_bucket_start")
|
||||
.bind("gpt-5.5")
|
||||
.bind(2)
|
||||
.bind(120)
|
||||
.bind(40)
|
||||
.bind(20)
|
||||
.bind("2026-06-29T12:59:42.044220420+00:00")
|
||||
.execute(&pool)
|
||||
.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_model_window_bucket_start")
|
||||
.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"]["models"][0]["model"], "gpt-5.5");
|
||||
assert_eq!(item["usage"]["models"][0]["requestCount"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_accounts_list_should_return_numbered_page_with_search_total() {
|
||||
let (app, _state, pool, _dir) =
|
||||
|
||||
@@ -322,7 +322,7 @@ async fn dashboard_summary_should_order_account_usage_by_recent_usage() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_return_seven_day_health_timeline() {
|
||||
async fn dashboard_summary_should_return_today_health_timeline() {
|
||||
let (app, store, _pool, _dir) = dashboard_test_app(
|
||||
"dashboard-health-timeline.sqlite",
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
@@ -344,8 +344,12 @@ async fn dashboard_summary_should_return_seven_day_health_timeline() {
|
||||
let points = body["data"]["healthTimeline"]["points"].as_str().unwrap();
|
||||
|
||||
assert_eq!(body["data"]["healthTimeline"]["title"], "请求健康时间线");
|
||||
assert_eq!(points.len(), 672);
|
||||
assert_eq!(points.matches('1').count(), 1);
|
||||
assert_eq!(
|
||||
body["data"]["healthTimeline"]["description"],
|
||||
"今日请求可靠性"
|
||||
);
|
||||
assert_eq!(points.len(), 96);
|
||||
assert_eq!(points.matches('4').count(), 1);
|
||||
}
|
||||
|
||||
async fn dashboard_summary_total_cost_for_usage_record(
|
||||
|
||||
@@ -116,6 +116,26 @@ fn release_should_mark_runtime_usage_window() {
|
||||
assert!(released.last_used_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_should_start_usage_window_without_known_reset() {
|
||||
let now = fixed_time();
|
||||
let mut pool = AccountPool::default();
|
||||
pool.insert(crate::support::accounts::test_account(
|
||||
"acct_a",
|
||||
AccountStatus::Active,
|
||||
));
|
||||
|
||||
let acquired = pool
|
||||
.acquire_with(&AccountAcquireRequest::new("gpt-5.5", now))
|
||||
.unwrap();
|
||||
|
||||
pool.release(&acquired.account.id);
|
||||
let released = pool.get(&acquired.account.id).unwrap();
|
||||
|
||||
assert!(released.window_started_at.is_some());
|
||||
assert!(released.window_reset_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_should_mark_usage_after_stale_slot_cleanup() {
|
||||
let now = fixed_time();
|
||||
|
||||
@@ -252,10 +252,21 @@ async fn account_repository_should_accumulate_usage_counters() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let usage: (i64, i64, i64, i64, i64, i64, i64, Option<String>) = sqlx::query_as(
|
||||
let usage: (
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) = sqlx::query_as(
|
||||
r"
|
||||
select request_count, input_tokens, output_tokens, cached_tokens,
|
||||
reasoning_tokens, total_tokens, empty_response_count, last_used_at
|
||||
reasoning_tokens, total_tokens, empty_response_count,
|
||||
window_started_at, last_used_at
|
||||
from account_usage
|
||||
where account_id = ?
|
||||
",
|
||||
@@ -272,6 +283,7 @@ async fn account_repository_should_accumulate_usage_counters() {
|
||||
assert_eq!(usage.5, 24);
|
||||
assert_eq!(usage.6, 1);
|
||||
assert!(usage.7.is_some());
|
||||
assert!(usage.8.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -138,61 +138,56 @@ const quotaToneClasses: Record<string, string> = {
|
||||
<div
|
||||
class="grid px-4 pt-5 pb-6 lg:px-7 lg:pt-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7 xl:pb-0"
|
||||
>
|
||||
<section
|
||||
class="min-w-0 w-full shadow-[inset_0_-1px_0_var(--cp-default-border)] xl:h-100.5 xl:pb-0 xl:shadow-none"
|
||||
>
|
||||
<section class="min-w-0 w-full pb-6 xl:h-100.5 xl:pb-0">
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">账号调度</h2>
|
||||
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
|
||||
容量、并发与分配策略
|
||||
</p>
|
||||
|
||||
<div class="mt-7.75 h-30.5 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-4.5">
|
||||
<div class="mt-7.75 grid h-30.5 content-between rounded-[14px] bg-(--cp-bg-subtle) p-4">
|
||||
<span class="block h-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
|
||||
>槽位占用</span
|
||||
>
|
||||
<div class="mt-3 grid h-8.5 grid-cols-[minmax(0,1fr)_auto] items-start gap-4">
|
||||
<strong
|
||||
class="font-mono text-[32px] leading-[1.05] font-[760] tabular-nums text-(--cp-text-primary)"
|
||||
>{{ usedRatio }}</strong
|
||||
>
|
||||
<span class="mt-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
|
||||
>{{ usedPercent }}% 已占用</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-4.5 h-2.5 w-full overflow-hidden rounded-full bg-(--cp-progress-track)">
|
||||
<i
|
||||
class="block h-2.5 rounded-full bg-(--cp-success)"
|
||||
:style="{ width: `${usedPercent}%` }"
|
||||
/>
|
||||
<div>
|
||||
<div class="grid h-8.5 grid-cols-[minmax(0,1fr)_auto] items-start gap-4">
|
||||
<strong
|
||||
class="font-mono text-[32px] leading-[1.05] font-[760] tabular-nums text-(--cp-text-primary)"
|
||||
>{{ usedRatio }}</strong
|
||||
>
|
||||
<span class="mt-3.5 text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
|
||||
>{{ usedPercent }}% 已占用</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-4 h-2.5 w-full overflow-hidden rounded-full bg-(--cp-progress-track)">
|
||||
<i
|
||||
class="block h-2.5 rounded-full bg-(--cp-success)"
|
||||
:style="{ width: `${usedPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-4 grid h-22.5 grid-cols-3 gap-4 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-5"
|
||||
>
|
||||
<div v-for="stat in scheduleStats" :key="stat.label">
|
||||
<div class="mt-4 grid h-22.5 grid-cols-3 gap-4 rounded-[14px] bg-(--cp-bg-subtle) p-4">
|
||||
<div v-for="stat in scheduleStats" :key="stat.label" class="grid content-between">
|
||||
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">{{
|
||||
stat.label
|
||||
}}</span>
|
||||
<strong
|
||||
class="mt-3.25 block font-mono text-[21px] leading-[1.1] font-[760] tabular-nums text-(--cp-text-primary)"
|
||||
class="block font-mono text-[21px] leading-[1.1] font-[760] tabular-nums text-(--cp-text-primary)"
|
||||
>{{ stat.value }}</strong
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 h-20.5 rounded-[14px] bg-(--cp-bg-subtle) px-4 pt-4.25">
|
||||
<div class="mt-4 grid h-20.5 content-between rounded-[14px] bg-(--cp-bg-subtle) p-4">
|
||||
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">分配策略</span>
|
||||
<strong
|
||||
class="mt-2.5 block text-[17px] leading-[1.15] font-[650] text-(--cp-text-primary)"
|
||||
>{{ strategyLabel }}</strong
|
||||
>
|
||||
<strong class="block text-[17px] leading-[1.15] font-[650] text-(--cp-text-primary)">{{
|
||||
strategyLabel
|
||||
}}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="min-w-0 w-full py-6 shadow-[inset_0_-1px_0_var(--cp-default-border)] xl:py-0 xl:shadow-none"
|
||||
>
|
||||
<section class="min-w-0 w-full">
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">活跃账号用量</h2>
|
||||
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
|
||||
最近使用排序
|
||||
|
||||
@@ -7,12 +7,31 @@ const props = defineProps<{
|
||||
timeline: any
|
||||
}>()
|
||||
|
||||
const points = computed(() => props.timeline.points.split(''))
|
||||
const healthLegend = [
|
||||
{ code: '0', label: '未来' },
|
||||
{ code: '1', label: '无请求' },
|
||||
{ code: '2', label: '不可达' },
|
||||
{ code: '3', label: '不稳定' },
|
||||
{ code: '4', label: '低样本' },
|
||||
{ code: '5', label: '稳定' },
|
||||
]
|
||||
|
||||
const points = computed(() => String(props.timeline.points ?? '').split(''))
|
||||
const pointGroups = computed(() => {
|
||||
const groups: string[][] = []
|
||||
for (let index = 0; index < points.value.length; index += 4) {
|
||||
groups.push(points.value.slice(index, index + 4))
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function cellClass(point: string) {
|
||||
if (point === '1') return 'bg-(--cp-success)'
|
||||
if (point === '2') return 'bg-(--cp-warning)'
|
||||
if (point === '3') return 'bg-(--cp-danger)'
|
||||
if (point === '0') return 'bg-(--cp-disabled-bg) opacity-60'
|
||||
if (point === '1') return 'bg-(--cp-default-border-hover)'
|
||||
if (point === '2') return 'bg-(--cp-danger)'
|
||||
if (point === '3') return 'bg-(--cp-warning)'
|
||||
if (point === '4') return 'bg-(--cp-normal)'
|
||||
if (point === '5') return 'bg-(--cp-success)'
|
||||
return 'bg-(--cp-default-border-hover)'
|
||||
}
|
||||
</script>
|
||||
@@ -27,35 +46,46 @@ function cellClass(point: string) {
|
||||
class="w-full"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="grid gap-2 lg:justify-items-end">
|
||||
<span class="font-mono text-xs leading-none font-[650] text-(--cp-text-secondary)">
|
||||
{{ timeline.rangeDisplay }}
|
||||
</span>
|
||||
<strong class="font-mono text-sm leading-none font-[760] text-(--cp-success-text)">
|
||||
<div class="flex w-full flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<div
|
||||
class="flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 text-[11px] leading-none font-[650] text-(--cp-text-muted)"
|
||||
>
|
||||
<span
|
||||
v-for="item in healthLegend"
|
||||
:key="item.code"
|
||||
class="inline-flex h-3.5 items-center gap-1 align-middle leading-none"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="block size-2 shrink-0 rounded-xs"
|
||||
:class="cellClass(item.code)"
|
||||
/>
|
||||
<span class="block leading-none">{{ item.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<strong
|
||||
class="shrink-0 font-mono text-sm leading-none font-[760] tabular-nums text-(--cp-success-text)"
|
||||
>
|
||||
{{ timeline.reliabilityDisplay }}
|
||||
</strong>
|
||||
<div
|
||||
class="flex items-center gap-2 text-[11px] leading-none font-[650] text-(--cp-text-muted)"
|
||||
>
|
||||
<span>{{ timeline.oldestLabel }}</span>
|
||||
<i class="size-2 rounded-xs bg-(--cp-default-border-hover)" />
|
||||
<i class="size-2 rounded-xs bg-(--cp-danger)" />
|
||||
<i class="size-2 rounded-xs bg-(--cp-warning)" />
|
||||
<i class="size-2 rounded-xs bg-(--cp-success)" />
|
||||
<span>{{ timeline.newestLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="mt-4.25">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<i
|
||||
v-for="(point, index) in points"
|
||||
:key="index"
|
||||
class="size-2.5 rounded-[3px]"
|
||||
:class="cellClass(point)"
|
||||
/>
|
||||
<div class="grid w-full grid-cols-[repeat(24,minmax(0,1fr))] items-end gap-[3px]">
|
||||
<div
|
||||
v-for="(group, groupIndex) in pointGroups"
|
||||
:key="groupIndex"
|
||||
class="grid grid-cols-4 items-end gap-[2px]"
|
||||
>
|
||||
<i
|
||||
v-for="(point, pointIndex) in group"
|
||||
:key="groupIndex * 4 + pointIndex"
|
||||
class="h-3.5 min-w-[2px] rounded-[2px]"
|
||||
:class="cellClass(point)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -229,13 +229,7 @@ function summaryMarkerStyle(item: any) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard
|
||||
as="article"
|
||||
variant="dashboard"
|
||||
title="使用趋势"
|
||||
description="最近 24 小时"
|
||||
class="min-h-95 w-full"
|
||||
>
|
||||
<BaseCard as="article" variant="dashboard" title="使用趋势" class="min-h-95 w-full">
|
||||
<template #actions>
|
||||
<BaseSegmented
|
||||
v-model="activeTab"
|
||||
|
||||
@@ -141,7 +141,7 @@ export function useDashboard(): any {
|
||||
},
|
||||
healthTimeline: {
|
||||
title: '请求健康时间线',
|
||||
description: '最近 7 天请求可靠性',
|
||||
description: '今日请求可靠性',
|
||||
rangeDisplay: '-',
|
||||
reliabilityDisplay: '-',
|
||||
oldestLabel: '最早',
|
||||
|
||||
@@ -38,7 +38,7 @@ const {
|
||||
系统概览
|
||||
</h1>
|
||||
<p class="mt-2.5 text-[15px] leading-[1.15] font-semibold mb-0 text-(--cp-text-secondary)">
|
||||
生产环境 · 自动刷新 30s
|
||||
今日统计 · 自动刷新 30s
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user