mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
fix(dashboard): 修复今日统计口径并对齐每日统计日期显示
- 前端请求 /api/dashboard/stats 时传递 timezone 和 tz_offset_minutes - 后端仪表盘汇总过滤 pending/streaming 和占位 provider,修正今日请求/Token/费用统计 - 今日 Token 卡片增加 K/M 单位显示,并补充写缓存/读缓存 Token 信息 - 修复每日统计 YYYY-MM-DD 被按 UTC 解析导致的“今天/昨天”串天问题 - 补充前后端回归测试,覆盖统计口径和日期解析场景
This commit is contained in:
@@ -117,6 +117,11 @@ impl DashboardUsageTotals {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dashboard_usage_should_count_in_summary(item: &StoredRequestUsageAudit) -> bool {
|
||||||
|
!matches!(item.status.as_str(), "pending" | "streaming")
|
||||||
|
&& !matches!(item.provider_name.as_str(), "unknown" | "pending")
|
||||||
|
}
|
||||||
|
|
||||||
fn dashboard_cache_creation_tokens(item: &StoredRequestUsageAudit) -> u64 {
|
fn dashboard_cache_creation_tokens(item: &StoredRequestUsageAudit) -> u64 {
|
||||||
let classified = item
|
let classified = item
|
||||||
.cache_creation_ephemeral_5m_input_tokens
|
.cache_creation_ephemeral_5m_input_tokens
|
||||||
@@ -162,6 +167,39 @@ fn dashboard_format_integer(value: u64) -> String {
|
|||||||
formatted.chars().rev().collect()
|
formatted.chars().rev().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dashboard_trimmed_decimal(value: f64, decimals: usize) -> String {
|
||||||
|
let mut formatted = format!("{value:.decimals$}");
|
||||||
|
while formatted.contains('.') && formatted.ends_with('0') {
|
||||||
|
formatted.pop();
|
||||||
|
}
|
||||||
|
if formatted.ends_with('.') {
|
||||||
|
formatted.pop();
|
||||||
|
}
|
||||||
|
formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dashboard_format_token_compact(value: u64) -> String {
|
||||||
|
if value < 1_000 {
|
||||||
|
return dashboard_format_integer(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if value < 1_000_000 {
|
||||||
|
let thousands = value as f64 / 1_000.0;
|
||||||
|
if thousands >= 100.0 {
|
||||||
|
return format!("{}K", thousands.round() as u64);
|
||||||
|
}
|
||||||
|
let decimals = if thousands >= 10.0 { 1 } else { 2 };
|
||||||
|
return format!("{}K", dashboard_trimmed_decimal(thousands, decimals));
|
||||||
|
}
|
||||||
|
|
||||||
|
let millions = value as f64 / 1_000_000.0;
|
||||||
|
if millions >= 100.0 {
|
||||||
|
return format!("{}M", millions.round() as u64);
|
||||||
|
}
|
||||||
|
let decimals = if millions >= 10.0 { 1 } else { 2 };
|
||||||
|
format!("{}M", dashboard_trimmed_decimal(millions, decimals))
|
||||||
|
}
|
||||||
|
|
||||||
fn dashboard_format_usd(value: f64) -> String {
|
fn dashboard_format_usd(value: f64) -> String {
|
||||||
format!("${:.2}", dashboard_round_f64(value, 2))
|
format!("${:.2}", dashboard_round_f64(value, 2))
|
||||||
}
|
}
|
||||||
@@ -178,6 +216,16 @@ fn dashboard_format_token_subvalue(totals: &DashboardUsageTotals) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dashboard_format_today_token_subvalue(totals: &DashboardUsageTotals) -> String {
|
||||||
|
format!(
|
||||||
|
"输入 {} / 输出 {} · 写缓存 {} / 读缓存 {}",
|
||||||
|
dashboard_format_token_compact(totals.input_tokens),
|
||||||
|
dashboard_format_token_compact(totals.output_tokens),
|
||||||
|
dashboard_format_token_compact(totals.cache_creation_tokens),
|
||||||
|
dashboard_format_token_compact(totals.cache_read_tokens)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn dashboard_parse_tz_offset_minutes(query: Option<&str>) -> Result<i32, String> {
|
fn dashboard_parse_tz_offset_minutes(query: Option<&str>) -> Result<i32, String> {
|
||||||
query_param_value(query, "tz_offset_minutes")
|
query_param_value(query, "tz_offset_minutes")
|
||||||
.map(|value| {
|
.map(|value| {
|
||||||
@@ -426,7 +474,10 @@ async fn dashboard_list_usage_for_range(
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(value) => Ok(value),
|
Ok(mut value) => {
|
||||||
|
value.retain(dashboard_usage_should_count_in_summary);
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
Err(err) => Err(build_auth_error_response(
|
Err(err) => Err(build_auth_error_response(
|
||||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("{error_context}: {err:?}"),
|
format!("{error_context}: {err:?}"),
|
||||||
@@ -612,8 +663,8 @@ pub(super) async fn handle_dashboard_stats_get(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "今日 Token",
|
"name": "今日 Token",
|
||||||
"value": dashboard_format_integer(today_totals.total_tokens),
|
"value": dashboard_format_token_compact(today_totals.total_tokens),
|
||||||
"subValue": dashboard_format_token_subvalue(&today_totals),
|
"subValue": dashboard_format_today_token_subvalue(&today_totals),
|
||||||
"icon": "Zap",
|
"icon": "Zap",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ async fn gateway_handles_dashboard_stats_locally_without_proxying_upstream() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream() {
|
async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream() {
|
||||||
let now = Utc::now();
|
let now = stable_dashboard_now();
|
||||||
let admin = StoredUserAuthRecord::new(
|
let admin = StoredUserAuthRecord::new(
|
||||||
"admin-auth-1".to_string(),
|
"admin-auth-1".to_string(),
|
||||||
Some("admin@example.com".to_string()),
|
Some("admin@example.com".to_string()),
|
||||||
@@ -213,25 +213,54 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
|
|||||||
"refresh-dashboard-stats-admin",
|
"refresh-dashboard-stats-admin",
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
let mut openai_usage = sample_user_usage_audit(
|
||||||
|
"usage-dashboard-admin-1",
|
||||||
|
"req-dashboard-admin-1",
|
||||||
|
"user-auth-1",
|
||||||
|
"gpt-5",
|
||||||
|
"openai",
|
||||||
|
"completed",
|
||||||
|
now - chrono::Duration::minutes(10),
|
||||||
|
);
|
||||||
|
openai_usage.input_tokens = 12_000;
|
||||||
|
openai_usage.output_tokens = 3_000;
|
||||||
|
openai_usage.total_tokens = 15_000;
|
||||||
|
openai_usage.cache_creation_input_tokens = 1_200;
|
||||||
|
openai_usage.cache_creation_ephemeral_5m_input_tokens = 600;
|
||||||
|
openai_usage.cache_creation_ephemeral_1h_input_tokens = 600;
|
||||||
|
openai_usage.cache_read_input_tokens = 800;
|
||||||
|
|
||||||
|
let mut claude_usage = sample_user_usage_audit(
|
||||||
|
"usage-dashboard-admin-2",
|
||||||
|
"req-dashboard-admin-2",
|
||||||
|
"user-auth-2",
|
||||||
|
"claude-3-7",
|
||||||
|
"claude",
|
||||||
|
"completed",
|
||||||
|
now - chrono::Duration::minutes(5),
|
||||||
|
);
|
||||||
|
claude_usage.input_tokens = 900;
|
||||||
|
claude_usage.output_tokens = 100;
|
||||||
|
claude_usage.total_tokens = 1_000;
|
||||||
|
claude_usage.cache_creation_input_tokens = 50;
|
||||||
|
claude_usage.cache_creation_ephemeral_5m_input_tokens = 20;
|
||||||
|
claude_usage.cache_creation_ephemeral_1h_input_tokens = 30;
|
||||||
|
claude_usage.cache_read_input_tokens = 200;
|
||||||
|
|
||||||
|
let streaming_usage = sample_user_usage_audit(
|
||||||
|
"usage-dashboard-admin-3",
|
||||||
|
"req-dashboard-admin-3",
|
||||||
|
"user-auth-3",
|
||||||
|
"gpt-4.1",
|
||||||
|
"openai",
|
||||||
|
"streaming",
|
||||||
|
now - chrono::Duration::minutes(1),
|
||||||
|
);
|
||||||
|
|
||||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
||||||
sample_user_usage_audit(
|
openai_usage,
|
||||||
"usage-dashboard-admin-1",
|
claude_usage,
|
||||||
"req-dashboard-admin-1",
|
streaming_usage,
|
||||||
"user-auth-1",
|
|
||||||
"gpt-5",
|
|
||||||
"openai",
|
|
||||||
"completed",
|
|
||||||
now - chrono::Duration::minutes(10),
|
|
||||||
),
|
|
||||||
sample_user_usage_audit(
|
|
||||||
"usage-dashboard-admin-2",
|
|
||||||
"req-dashboard-admin-2",
|
|
||||||
"user-auth-2",
|
|
||||||
"claude-3-7",
|
|
||||||
"claude",
|
|
||||||
"completed",
|
|
||||||
now - chrono::Duration::minutes(5),
|
|
||||||
),
|
|
||||||
]));
|
]));
|
||||||
let user_repository = Arc::new(
|
let user_repository = Arc::new(
|
||||||
InMemoryUserReadRepository::seed_auth_users(vec![admin.clone()]).with_export_users(vec![
|
InMemoryUserReadRepository::seed_auth_users(vec![admin.clone()]).with_export_users(vec![
|
||||||
@@ -367,6 +396,15 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
|
|||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["today"]["requests"], 2);
|
||||||
|
assert_eq!(payload["today"]["tokens"], 16_000);
|
||||||
|
assert_eq!(payload["today"]["cost"], json!(2.5));
|
||||||
|
assert_eq!(payload["stats"][0]["value"], json!("2"));
|
||||||
|
assert_eq!(payload["stats"][1]["value"], json!("16K"));
|
||||||
|
assert_eq!(
|
||||||
|
payload["stats"][1]["subValue"],
|
||||||
|
json!("输入 12.9K / 输出 3.1K · 写缓存 1.25K / 读缓存 1K")
|
||||||
|
);
|
||||||
assert_eq!(payload["users"]["total"], 2);
|
assert_eq!(payload["users"]["total"], 2);
|
||||||
assert_eq!(payload["users"]["active"], 1);
|
assert_eq!(payload["users"]["active"], 1);
|
||||||
assert_eq!(payload["api_keys"]["total"], 3);
|
assert_eq!(payload["api_keys"]["total"], 3);
|
||||||
|
|||||||
19
frontend/src/utils/__tests__/date.spec.ts
Normal file
19
frontend/src/utils/__tests__/date.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { parseDateLike } from '../date'
|
||||||
|
|
||||||
|
describe('parseDateLike', () => {
|
||||||
|
it('parses date-only strings as local calendar dates', () => {
|
||||||
|
const date = parseDateLike('2026-04-12')
|
||||||
|
|
||||||
|
expect(date.getFullYear()).toBe(2026)
|
||||||
|
expect(date.getMonth()).toBe(3)
|
||||||
|
expect(date.getDate()).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps timestamp strings delegated to native Date parsing', () => {
|
||||||
|
const date = parseDateLike('2026-04-12T15:30:00Z')
|
||||||
|
|
||||||
|
expect(Number.isNaN(date.getTime())).toBe(false)
|
||||||
|
expect(date.toISOString()).toBe('2026-04-12T15:30:00.000Z')
|
||||||
|
})
|
||||||
|
})
|
||||||
15
frontend/src/utils/date.ts
Normal file
15
frontend/src/utils/date.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 `YYYY-MM-DD` 解析为本地时区日期,避免浏览器按 UTC 解析后串天。
|
||||||
|
* 其他带时间/时区的信息仍交给原生 Date 处理。
|
||||||
|
*/
|
||||||
|
export function parseDateLike(dateString: string): Date {
|
||||||
|
const matched = DATE_ONLY_PATTERN.exec(dateString)
|
||||||
|
if (!matched) {
|
||||||
|
return new Date(dateString)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, year, month, day] = matched
|
||||||
|
return new Date(Number(year), Number(month) - 1, Number(day))
|
||||||
|
}
|
||||||
@@ -819,6 +819,7 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
Key,
|
Key,
|
||||||
Hash,
|
Hash,
|
||||||
|
Zap,
|
||||||
Bell,
|
Bell,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -830,6 +831,7 @@ import {
|
|||||||
Shuffle
|
Shuffle
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||||
|
import { parseDateLike } from '@/utils/date'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||||
import type { ChartData, ChartOptions, ChartDataset, TooltipItem } from 'chart.js'
|
import type { ChartData, ChartOptions, ChartDataset, TooltipItem } from 'chart.js'
|
||||||
@@ -1000,7 +1002,7 @@ const selectedAnnouncement = ref<Announcement | null>(null)
|
|||||||
const detailDialogOpen = ref(false)
|
const detailDialogOpen = ref(false)
|
||||||
|
|
||||||
const iconMap: Record<string, unknown> = {
|
const iconMap: Record<string, unknown> = {
|
||||||
Users, Activity, TrendingUp, DollarSign, Key, Hash, Database
|
Users, Activity, TrendingUp, DollarSign, Key, Hash, Zap, Database
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空状态占位卡片
|
// 空状态占位卡片
|
||||||
@@ -1315,7 +1317,10 @@ onBeforeUnmount(() => {
|
|||||||
async function loadDashboardData() {
|
async function loadDashboardData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const statsData = await dashboardApi.getStats()
|
const statsData = await dashboardApi.getStats({
|
||||||
|
timezone: dailyTimeRange.value.timezone,
|
||||||
|
tz_offset_minutes: dailyTimeRange.value.tz_offset_minutes
|
||||||
|
})
|
||||||
stats.value = statsData.stats.map(stat => ({
|
stats.value = statsData.stats.map(stat => ({
|
||||||
...stat,
|
...stat,
|
||||||
icon: iconMap[stat.icon] || Activity
|
icon: iconMap[stat.icon] || Activity
|
||||||
@@ -1382,7 +1387,7 @@ function scheduleDailyStatsLoad() {
|
|||||||
watch(dailyTimeRange, scheduleDailyStatsLoad, { deep: true })
|
watch(dailyTimeRange, scheduleDailyStatsLoad, { deep: true })
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
const date = new Date(dateString)
|
const date = parseDateLike(dateString)
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const yesterday = new Date(today)
|
const yesterday = new Date(today)
|
||||||
yesterday.setDate(yesterday.getDate() - 1)
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
@@ -1392,7 +1397,7 @@ function formatDate(dateString: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDateForChart(dateString: string): string {
|
function formatDateForChart(dateString: string): string {
|
||||||
const date = new Date(dateString)
|
const date = parseDateLike(dateString)
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const yesterday = new Date(today)
|
const yesterday = new Date(today)
|
||||||
yesterday.setDate(yesterday.getDate() - 1)
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user