From 600408464452a93226c14bab249cbb3ef757deb3 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Wed, 31 Dec 2025 17:00:53 +0800 Subject: [PATCH] Align usage stats to sliding windows --- src-tauri/src/commands/usage.rs | 5 +- src-tauri/src/services/usage_stats.rs | 241 +++++++++------------ src/components/usage/UsageSummaryCards.tsx | 8 +- src/components/usage/UsageTrendChart.tsx | 28 +-- src/i18n/locales/en.json | 2 +- src/i18n/locales/ja.json | 2 +- src/i18n/locales/zh.json | 2 +- src/lib/api/usage.ts | 7 +- src/lib/query/usage.ts | 23 +- 9 files changed, 141 insertions(+), 177 deletions(-) diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index e872feb5d..4e527ebbe 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -19,9 +19,10 @@ pub fn get_usage_summary( #[tauri::command] pub fn get_usage_trends( state: State<'_, AppState>, - days: u32, + start_date: Option, + end_date: Option, ) -> Result, AppError> { - state.db.get_daily_trends(days) + state.db.get_daily_trends(start_date, end_date) } /// 获取 Provider 统计 diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 8c3dab835..741648751 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -4,7 +4,7 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; -use chrono::{Duration, Local, TimeZone}; +use chrono::{Local, TimeZone}; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -181,145 +181,114 @@ impl Database { Ok(result) } - /// 获取每日趋势 - pub fn get_daily_trends(&self, days: u32) -> Result, AppError> { + /// 获取每日趋势(滑动窗口,<=24h 按小时,>24h 按天,窗口与汇总一致) + pub fn get_daily_trends( + &self, + start_date: Option, + end_date: Option, + ) -> Result, AppError> { let conn = lock_conn!(self.conn); - if days <= 1 { - let today = Local::now().date_naive(); - let start_of_today = today.and_hms_opt(0, 0, 0).unwrap(); - // 使用 earliest() 处理 DST 切换时的歧义时间,fallback 到当前时间减一天 - let start_ts = Local - .from_local_datetime(&start_of_today) - .earliest() - .unwrap_or_else(|| Local::now() - Duration::days(1)) - .timestamp(); + let end_ts = end_date.unwrap_or_else(|| Local::now().timestamp()); + let mut start_ts = start_date.unwrap_or_else(|| end_ts - 24 * 60 * 60); - let sql = "SELECT - strftime('%Y-%m-%dT%H:00:00', datetime(created_at, 'unixepoch', 'localtime')) as bucket, - COUNT(*) as request_count, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, - COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens - FROM proxy_request_logs - WHERE created_at >= ? - GROUP BY bucket - ORDER BY bucket ASC"; - - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([start_ts], |row| { - Ok(DailyStats { - date: row.get(0)?, - request_count: row.get::<_, i64>(1)? as u64, - total_cost: format!("{:.6}", row.get::<_, f64>(2)?), - total_tokens: row.get::<_, i64>(3)? as u64, - total_input_tokens: row.get::<_, i64>(4)? as u64, - total_output_tokens: row.get::<_, i64>(5)? as u64, - total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, - total_cache_read_tokens: row.get::<_, i64>(7)? as u64, - }) - })?; - - let mut buckets: HashMap = HashMap::new(); - for row in rows { - let stat = row?; - buckets.insert(stat.date.clone(), stat); - } - - let mut stats = Vec::new(); - for hour in 0..24 { - let bucket = today - .and_hms_opt(hour, 0, 0) - .unwrap() - .format("%Y-%m-%dT%H:00:00") - .to_string(); - - if let Some(stat) = buckets.remove(&bucket) { - stats.push(stat); - } else { - stats.push(DailyStats { - date: bucket, - request_count: 0, - total_cost: "0.000000".to_string(), - total_tokens: 0, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_creation_tokens: 0, - total_cache_read_tokens: 0, - }); - } - } - Ok(stats) - } else { - let today = Local::now().date_naive(); - let start_day = today - Duration::days((days.saturating_sub(1)) as i64); - let start_of_window = start_day.and_hms_opt(0, 0, 0).unwrap(); - // 使用 earliest() 处理 DST 切换时的歧义时间,fallback 到当前时间减 days 天 - let start_ts = Local - .from_local_datetime(&start_of_window) - .earliest() - .unwrap_or_else(|| Local::now() - Duration::days(days as i64)) - .timestamp(); - - let sql = "SELECT - strftime('%Y-%m-%dT00:00:00', datetime(created_at, 'unixepoch', 'localtime')) as bucket, - COUNT(*) as request_count, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, - COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens - FROM proxy_request_logs - WHERE created_at >= ? - GROUP BY bucket - ORDER BY bucket ASC"; - - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([start_ts], |row| { - Ok(DailyStats { - date: row.get(0)?, - request_count: row.get::<_, i64>(1)? as u64, - total_cost: format!("{:.6}", row.get::<_, f64>(2)?), - total_tokens: row.get::<_, i64>(3)? as u64, - total_input_tokens: row.get::<_, i64>(4)? as u64, - total_output_tokens: row.get::<_, i64>(5)? as u64, - total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, - total_cache_read_tokens: row.get::<_, i64>(7)? as u64, - }) - })?; - - let mut map = HashMap::new(); - for row in rows { - let stat = row?; - map.insert(stat.date.clone(), stat); - } - - let mut stats = Vec::new(); - - for i in 0..days { - let day = start_day + Duration::days(i as i64); - let key = day.format("%Y-%m-%dT00:00:00").to_string(); - if let Some(stat) = map.remove(&key) { - stats.push(stat); - } else { - stats.push(DailyStats { - date: key, - request_count: 0, - total_cost: "0.000000".to_string(), - total_tokens: 0, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_creation_tokens: 0, - total_cache_read_tokens: 0, - }); - } - } - Ok(stats) + if start_ts >= end_ts { + start_ts = end_ts - 24 * 60 * 60; } + + let duration = end_ts - start_ts; + let bucket_seconds: i64 = if duration <= 24 * 60 * 60 { + 60 * 60 + } else { + 24 * 60 * 60 + }; + let mut bucket_count: i64 = if duration <= 0 { + 1 + } else { + ((duration as f64) / bucket_seconds as f64).ceil() as i64 + }; + + // 固定 24 小时窗口为 24 个小时桶,避免浮点误差 + if bucket_seconds == 60 * 60 { + bucket_count = 24; + } + + if bucket_count < 1 { + bucket_count = 1; + } + + let sql = " + SELECT + CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx, + COUNT(*) as request_count, + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens + FROM proxy_request_logs + WHERE created_at >= ?1 AND created_at <= ?2 + GROUP BY bucket_idx + ORDER BY bucket_idx ASC"; + + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| { + Ok(( + row.get::<_, i64>(0)?, + DailyStats { + date: String::new(), + request_count: row.get::<_, i64>(1)? as u64, + total_cost: format!("{:.6}", row.get::<_, f64>(2)?), + total_tokens: row.get::<_, i64>(3)? as u64, + total_input_tokens: row.get::<_, i64>(4)? as u64, + total_output_tokens: row.get::<_, i64>(5)? as u64, + total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, + total_cache_read_tokens: row.get::<_, i64>(7)? as u64, + }, + )) + })?; + + let mut map: HashMap = HashMap::new(); + for row in rows { + let (mut bucket_idx, stat) = row?; + if bucket_idx < 0 { + continue; + } + if bucket_idx >= bucket_count { + bucket_idx = bucket_count - 1; + } + map.insert(bucket_idx, stat); + } + + let mut stats = Vec::with_capacity(bucket_count as usize); + for i in 0..bucket_count { + let bucket_start_ts = start_ts + i * bucket_seconds; + let bucket_start = Local + .timestamp_opt(bucket_start_ts, 0) + .single() + .unwrap_or_else(Local::now); + + let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string(); + + if let Some(mut stat) = map.remove(&i) { + stat.date = date; + stats.push(stat); + } else { + stats.push(DailyStats { + date, + request_count: 0, + total_cost: "0.000000".to_string(), + total_tokens: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + }); + } + } + + Ok(stats) } /// 获取 Provider 统计 diff --git a/src/components/usage/UsageSummaryCards.tsx b/src/components/usage/UsageSummaryCards.tsx index 5ccae8651..f3a6176ba 100644 --- a/src/components/usage/UsageSummaryCards.tsx +++ b/src/components/usage/UsageSummaryCards.tsx @@ -12,13 +12,7 @@ interface UsageSummaryCardsProps { export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { const { t } = useTranslation(); - const { startDate, endDate } = useMemo(() => { - const end = Math.floor(Date.now() / 1000); - const start = end - days * 24 * 60 * 60; - return { startDate: start, endDate: end }; - }, [days]); - - const { data: summary, isLoading } = useUsageSummary(startDate, endDate); + const { data: summary, isLoading } = useUsageSummary(days); const stats = useMemo(() => { const totalRequests = summary?.totalRequests ?? 0; diff --git a/src/components/usage/UsageTrendChart.tsx b/src/components/usage/UsageTrendChart.tsx index c385fe06a..376014e02 100644 --- a/src/components/usage/UsageTrendChart.tsx +++ b/src/components/usage/UsageTrendChart.tsx @@ -41,7 +41,12 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { return { rawDate: stat.date, label: isToday - ? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" }) + ? pointDate.toLocaleString(dateLocale, { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) : pointDate.toLocaleDateString(dateLocale, { month: "2-digit", day: "2-digit", @@ -55,26 +60,7 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { }; }) || []; - const hourlyData = (() => { - if (!isToday) return chartData; - const map = new Map(); - chartData.forEach((point) => { - map.set(point.hour ?? 0, point); - }); - return Array.from({ length: 24 }, (_, hour) => { - const bucket = map.get(hour); - return { - label: `${hour.toString().padStart(2, "0")}:00`, - inputTokens: bucket?.inputTokens ?? 0, - outputTokens: bucket?.outputTokens ?? 0, - cacheCreationTokens: bucket?.cacheCreationTokens ?? 0, - cacheReadTokens: bucket?.cacheReadTokens ?? 0, - cost: bucket?.cost ?? 0, - }; - }); - })(); - - const displayData = isToday ? hourlyData : chartData; + const displayData = chartData; const CustomTooltip = ({ active, payload, label }: any) => { if (active && payload && payload.length) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 547ff496f..b103da09c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -423,7 +423,7 @@ "cost": "Cost", "perMillion": "(per million)", "trends": "Usage Trends", - "rangeToday": "Today (hourly)", + "rangeToday": "Last 24 hours (hourly)", "rangeLast7Days": "Last 7 days", "rangeLast30Days": "Last 30 days", "totalTokens": "Total Tokens", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 556d6383f..51de11899 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -423,7 +423,7 @@ "cost": "コスト", "perMillion": "(100万あたり)", "trends": "利用トレンド", - "rangeToday": "今日 (時間別)", + "rangeToday": "直近24時間 (時間別)", "rangeLast7Days": "過去7日間", "rangeLast30Days": "過去30日間", "totalTokens": "総トークン数", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f39ec889f..00208c3a3 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -423,7 +423,7 @@ "cost": "成本", "perMillion": "(每百万)", "trends": "使用趋势", - "rangeToday": "今天 (按小时)", + "rangeToday": "过去 24 小时 (按小时)", "rangeLast7Days": "过去 7 天", "rangeLast30Days": "过去 30 天", "totalTokens": "总 Token 数", diff --git a/src/lib/api/usage.ts b/src/lib/api/usage.ts index 7d6dcdbb6..4224973f3 100644 --- a/src/lib/api/usage.ts +++ b/src/lib/api/usage.ts @@ -49,8 +49,11 @@ export const usageApi = { return invoke("get_usage_summary", { startDate, endDate }); }, - getUsageTrends: async (days: number): Promise => { - return invoke("get_usage_trends", { days }); + getUsageTrends: async ( + startDate?: number, + endDate?: number, + ): Promise => { + return invoke("get_usage_trends", { startDate, endDate }); }, getProviderStats: async (): Promise => { diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts index 7ed145174..bf220c549 100644 --- a/src/lib/query/usage.ts +++ b/src/lib/query/usage.ts @@ -5,8 +5,7 @@ import type { LogFilters } from "@/types/usage"; // Query keys export const usageKeys = { all: ["usage"] as const, - summary: (startDate?: number, endDate?: number) => - [...usageKeys.all, "summary", startDate, endDate] as const, + summary: (days: number) => [...usageKeys.all, "summary", days] as const, trends: (days: number) => [...usageKeys.all, "trends", days] as const, providerStats: () => [...usageKeys.all, "provider-stats"] as const, modelStats: () => [...usageKeys.all, "model-stats"] as const, @@ -19,11 +18,20 @@ export const usageKeys = { [...usageKeys.all, "limits", providerId, appType] as const, }; +const getWindow = (days: number) => { + const endDate = Math.floor(Date.now() / 1000); + const startDate = endDate - days * 24 * 60 * 60; + return { startDate, endDate }; +}; + // Hooks -export function useUsageSummary(startDate?: number, endDate?: number) { +export function useUsageSummary(days: number) { return useQuery({ - queryKey: usageKeys.summary(startDate, endDate), - queryFn: () => usageApi.getUsageSummary(startDate, endDate), + queryKey: usageKeys.summary(days), + queryFn: () => { + const { startDate, endDate } = getWindow(days); + return usageApi.getUsageSummary(startDate, endDate); + }, refetchInterval: 30000, // 每30秒自动刷新 refetchIntervalInBackground: false, // 后台不刷新 }); @@ -32,7 +40,10 @@ export function useUsageSummary(startDate?: number, endDate?: number) { export function useUsageTrends(days: number) { return useQuery({ queryKey: usageKeys.trends(days), - queryFn: () => usageApi.getUsageTrends(days), + queryFn: () => { + const { startDate, endDate } = getWindow(days); + return usageApi.getUsageTrends(startDate, endDate); + }, refetchInterval: 30000, // 每30秒自动刷新 refetchIntervalInBackground: false, });