diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 79a3280fa..8c9047310 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -35,18 +35,26 @@ pub fn get_usage_trends( #[tauri::command] pub fn get_provider_stats( state: State<'_, AppState>, + start_date: Option, + end_date: Option, app_type: Option, ) -> Result, AppError> { - state.db.get_provider_stats(app_type.as_deref()) + state + .db + .get_provider_stats(start_date, end_date, app_type.as_deref()) } /// 获取模型统计 #[tauri::command] pub fn get_model_stats( state: State<'_, AppState>, + start_date: Option, + end_date: Option, app_type: Option, ) -> Result, AppError> { - state.db.get_model_stats(app_type.as_deref()) + state + .db + .get_model_stats(start_date, end_date, app_type.as_deref()) } /// 获取请求日志列表 diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index b43ea714a..aaeb72662 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -275,14 +275,9 @@ impl Database { let mut bucket_count: i64 = if duration <= 0 { 1 } else { - ((duration as f64) / bucket_seconds as f64).ceil() as i64 + (duration + bucket_seconds - 1) / bucket_seconds }; - // 固定 24 小时窗口为 24 个小时桶,避免浮点误差 - if bucket_seconds == 60 * 60 { - bucket_count = 24; - } - if bucket_count < 1 { bucket_count = 1; } @@ -453,14 +448,50 @@ impl Database { /// 获取 Provider 统计 pub fn get_provider_stats( &self, + start_date: Option, + end_date: Option, app_type: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); - let (detail_where, rollup_where) = if app_type.is_some() { - ("WHERE l.app_type = ?1", "WHERE r.app_type = ?2") + let mut detail_conditions = Vec::new(); + let mut detail_params: Vec> = Vec::new(); + if let Some(start) = start_date { + detail_conditions.push("l.created_at >= ?"); + detail_params.push(Box::new(start)); + } + if let Some(end) = end_date { + detail_conditions.push("l.created_at <= ?"); + detail_params.push(Box::new(end)); + } + if let Some(at) = app_type { + detail_conditions.push("l.app_type = ?"); + detail_params.push(Box::new(at.to_string())); + } + let detail_where = if detail_conditions.is_empty() { + String::new() } else { - ("", "") + format!("WHERE {}", detail_conditions.join(" AND ")) + }; + + let mut rollup_conditions = Vec::new(); + let mut rollup_params: Vec> = Vec::new(); + if let Some(start) = start_date { + rollup_conditions.push("r.date >= date(?, 'unixepoch', 'localtime')".to_string()); + rollup_params.push(Box::new(start)); + } + if let Some(end) = end_date { + rollup_conditions.push("r.date <= date(?, 'unixepoch', 'localtime')".to_string()); + rollup_params.push(Box::new(end)); + } + if let Some(at) = app_type { + rollup_conditions.push("r.app_type = ?".to_string()); + rollup_params.push(Box::new(at.to_string())); + } + let rollup_where = if rollup_conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", rollup_conditions.join(" AND ")) }; // UNION detail logs + rollup data, then aggregate @@ -506,6 +537,9 @@ impl Database { ); let mut stmt = conn.prepare(&sql)?; + let mut params: Vec> = detail_params; + params.extend(rollup_params); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let row_mapper = |row: &rusqlite::Row| { let request_count: i64 = row.get(3)?; let success_count: i64 = row.get(6)?; @@ -526,11 +560,7 @@ impl Database { }) }; - let rows = if let Some(at) = app_type { - stmt.query_map(params![at, at], row_mapper)? - } else { - stmt.query_map([], row_mapper)? - }; + let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?; let mut stats = Vec::new(); for row in rows { @@ -541,13 +571,52 @@ impl Database { } /// 获取模型统计 - pub fn get_model_stats(&self, app_type: Option<&str>) -> Result, AppError> { + pub fn get_model_stats( + &self, + start_date: Option, + end_date: Option, + app_type: Option<&str>, + ) -> Result, AppError> { let conn = lock_conn!(self.conn); - let (detail_where, rollup_where) = if app_type.is_some() { - ("WHERE app_type = ?1", "WHERE app_type = ?2") + let mut detail_conditions = Vec::new(); + let mut detail_params: Vec> = Vec::new(); + if let Some(start) = start_date { + detail_conditions.push("l.created_at >= ?"); + detail_params.push(Box::new(start)); + } + if let Some(end) = end_date { + detail_conditions.push("l.created_at <= ?"); + detail_params.push(Box::new(end)); + } + if let Some(at) = app_type { + detail_conditions.push("l.app_type = ?"); + detail_params.push(Box::new(at.to_string())); + } + let detail_where = if detail_conditions.is_empty() { + String::new() } else { - ("", "") + format!("WHERE {}", detail_conditions.join(" AND ")) + }; + + let mut rollup_conditions = Vec::new(); + let mut rollup_params: Vec> = Vec::new(); + if let Some(start) = start_date { + rollup_conditions.push("r.date >= date(?, 'unixepoch', 'localtime')".to_string()); + rollup_params.push(Box::new(start)); + } + if let Some(end) = end_date { + rollup_conditions.push("r.date <= date(?, 'unixepoch', 'localtime')".to_string()); + rollup_params.push(Box::new(end)); + } + if let Some(at) = app_type { + rollup_conditions.push("r.app_type = ?".to_string()); + rollup_params.push(Box::new(at.to_string())); + } + let rollup_where = if rollup_conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", rollup_conditions.join(" AND ")) }; // UNION detail logs + rollup data @@ -558,27 +627,30 @@ impl Database { SUM(total_tokens) as total_tokens, SUM(total_cost) as total_cost FROM ( - SELECT model, + SELECT l.model, COUNT(*) as request_count, - COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost - FROM proxy_request_logs + COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens, + COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost + FROM proxy_request_logs l {detail_where} - GROUP BY model + GROUP BY l.model UNION ALL - SELECT model, + SELECT r.model, COALESCE(SUM(request_count), 0), COALESCE(SUM(input_tokens + output_tokens), 0), COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) - FROM usage_daily_rollups + FROM usage_daily_rollups r {rollup_where} - GROUP BY model + GROUP BY r.model ) GROUP BY model ORDER BY total_cost DESC" ); let mut stmt = conn.prepare(&sql)?; + let mut params: Vec> = detail_params; + params.extend(rollup_params); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let row_mapper = |row: &rusqlite::Row| { let request_count: i64 = row.get(1)?; let total_cost: f64 = row.get(3)?; @@ -597,11 +669,7 @@ impl Database { }) }; - let rows = if let Some(at) = app_type { - stmt.query_map(params![at, at], row_mapper)? - } else { - stmt.query_map([], row_mapper)? - }; + let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?; let mut stats = Vec::new(); for row in rows { @@ -1168,7 +1236,7 @@ mod tests { )?; } - let stats = db.get_model_stats(None)?; + let stats = db.get_model_stats(None, None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].model, "claude-3-sonnet"); assert_eq!(stats[0].request_count, 1); @@ -1176,6 +1244,73 @@ mod tests { Ok(()) } + #[test] + fn test_get_provider_stats_with_time_filter() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params!["old", "p1", "claude", "claude-3", 100, 50, "0.01", 100, 200, 1000], + )?; + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params!["new", "p1", "claude", "claude-3", 200, 75, "0.02", 120, 200, 2000], + )?; + } + + let stats = db.get_provider_stats(Some(1500), Some(2500), Some("claude"))?; + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].provider_id, "p1"); + assert_eq!(stats[0].request_count, 1); + assert_eq!(stats[0].total_tokens, 275); + + Ok(()) + } + + #[test] + fn test_get_daily_trends_respects_shorter_than_24_hours() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "req-short", + "p1", + "claude", + "claude-3", + 100, + 50, + "0.01", + 100, + 200, + 10_800 + ], + )?; + } + + let stats = db.get_daily_trends(Some(0), Some(15 * 60 * 60), Some("claude"))?; + assert_eq!(stats.len(), 15); + assert_eq!(stats[3].request_count, 1); + + Ok(()) + } + #[test] fn test_model_pricing_matching() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src/lib/api/usage.ts b/src/lib/api/usage.ts index 1ff974705..33ef3c9a0 100644 --- a/src/lib/api/usage.ts +++ b/src/lib/api/usage.ts @@ -63,12 +63,20 @@ export const usageApi = { return invoke("get_usage_trends", { startDate, endDate, appType }); }, - getProviderStats: async (appType?: string): Promise => { - return invoke("get_provider_stats", { appType }); + getProviderStats: async ( + startDate?: number, + endDate?: number, + appType?: string, + ): Promise => { + return invoke("get_provider_stats", { startDate, endDate, appType }); }, - getModelStats: async (appType?: string): Promise => { - return invoke("get_model_stats", { appType }); + getModelStats: async ( + startDate?: number, + endDate?: number, + appType?: string, + ): Promise => { + return invoke("get_model_stats", { startDate, endDate, appType }); }, getRequestLogs: async ( diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts index 84babb1a3..ed565508d 100644 --- a/src/lib/query/usage.ts +++ b/src/lib/query/usage.ts @@ -1,6 +1,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { usageApi } from "@/lib/api/usage"; -import type { LogFilters } from "@/types/usage"; +import { resolveUsageRange } from "@/lib/usageRange"; +import type { LogFilters, UsageRangeSelection } from "@/types/usage"; const DEFAULT_REFETCH_INTERVAL_MS = 30000; @@ -9,51 +10,94 @@ type UsageQueryOptions = { refetchIntervalInBackground?: boolean; }; -type RequestLogsTimeMode = "rolling" | "fixed"; - type RequestLogsQueryArgs = { filters: LogFilters; - timeMode: RequestLogsTimeMode; + range: UsageRangeSelection; page?: number; pageSize?: number; - rollingWindowSeconds?: number; options?: UsageQueryOptions; }; type RequestLogsKey = { - timeMode: RequestLogsTimeMode; - rollingWindowSeconds?: number; + preset: UsageRangeSelection["preset"]; + customStartDate?: number; + customEndDate?: number; appType?: string; providerName?: string; model?: string; statusCode?: number; - startDate?: number; - endDate?: number; }; // Query keys export const usageKeys = { all: ["usage"] as const, - summary: (days: number, appType?: string) => - [...usageKeys.all, "summary", days, appType ?? "all"] as const, - trends: (days: number, appType?: string) => - [...usageKeys.all, "trends", days, appType ?? "all"] as const, - providerStats: (appType?: string) => - [...usageKeys.all, "provider-stats", appType ?? "all"] as const, - modelStats: (appType?: string) => - [...usageKeys.all, "model-stats", appType ?? "all"] as const, + summary: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + appType?: string, + ) => + [ + ...usageKeys.all, + "summary", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + appType ?? "all", + ] as const, + trends: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + appType?: string, + ) => + [ + ...usageKeys.all, + "trends", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + appType ?? "all", + ] as const, + providerStats: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + appType?: string, + ) => + [ + ...usageKeys.all, + "provider-stats", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + appType ?? "all", + ] as const, + modelStats: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + appType?: string, + ) => + [ + ...usageKeys.all, + "model-stats", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + appType ?? "all", + ] as const, logs: (key: RequestLogsKey, page: number, pageSize: number) => [ ...usageKeys.all, "logs", - key.timeMode, - key.rollingWindowSeconds ?? 0, + key.preset, + key.customStartDate ?? 0, + key.customEndDate ?? 0, key.appType ?? "", key.providerName ?? "", key.model ?? "", key.statusCode ?? -1, - key.startDate ?? 0, - key.endDate ?? 0, page, pageSize, ] as const, @@ -64,23 +108,22 @@ 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( - days: number, + range: UsageRangeSelection, appType?: string, options?: UsageQueryOptions, ) { const effectiveAppType = appType === "all" ? undefined : appType; return useQuery({ - queryKey: usageKeys.summary(days, appType), + queryKey: usageKeys.summary( + range.preset, + range.customStartDate, + range.customEndDate, + appType, + ), queryFn: () => { - const { startDate, endDate } = getWindow(days); + const { startDate, endDate } = resolveUsageRange(range); return usageApi.getUsageSummary(startDate, endDate, effectiveAppType); }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, @@ -89,15 +132,20 @@ export function useUsageSummary( } export function useUsageTrends( - days: number, + range: UsageRangeSelection, appType?: string, options?: UsageQueryOptions, ) { const effectiveAppType = appType === "all" ? undefined : appType; return useQuery({ - queryKey: usageKeys.trends(days, appType), + queryKey: usageKeys.trends( + range.preset, + range.customStartDate, + range.customEndDate, + appType, + ), queryFn: () => { - const { startDate, endDate } = getWindow(days); + const { startDate, endDate } = resolveUsageRange(range); return usageApi.getUsageTrends(startDate, endDate, effectiveAppType); }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, @@ -106,61 +154,70 @@ export function useUsageTrends( } export function useProviderStats( + range: UsageRangeSelection, appType?: string, options?: UsageQueryOptions, ) { const effectiveAppType = appType === "all" ? undefined : appType; return useQuery({ - queryKey: usageKeys.providerStats(appType), - queryFn: () => usageApi.getProviderStats(effectiveAppType), + queryKey: usageKeys.providerStats( + range.preset, + range.customStartDate, + range.customEndDate, + appType, + ), + queryFn: () => { + const { startDate, endDate } = resolveUsageRange(range); + return usageApi.getProviderStats(startDate, endDate, effectiveAppType); + }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, }); } -export function useModelStats(appType?: string, options?: UsageQueryOptions) { +export function useModelStats( + range: UsageRangeSelection, + appType?: string, + options?: UsageQueryOptions, +) { const effectiveAppType = appType === "all" ? undefined : appType; return useQuery({ - queryKey: usageKeys.modelStats(appType), - queryFn: () => usageApi.getModelStats(effectiveAppType), + queryKey: usageKeys.modelStats( + range.preset, + range.customStartDate, + range.customEndDate, + appType, + ), + queryFn: () => { + const { startDate, endDate } = resolveUsageRange(range); + return usageApi.getModelStats(startDate, endDate, effectiveAppType); + }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, }); } -const getRollingRange = (windowSeconds: number) => { - const endDate = Math.floor(Date.now() / 1000); - const startDate = endDate - windowSeconds; - return { startDate, endDate }; -}; - export function useRequestLogs({ filters, - timeMode, + range, page = 0, pageSize = 20, - rollingWindowSeconds = 24 * 60 * 60, options, }: RequestLogsQueryArgs) { const key: RequestLogsKey = { - timeMode, - rollingWindowSeconds: - timeMode === "rolling" ? rollingWindowSeconds : undefined, + preset: range.preset, + customStartDate: range.customStartDate, + customEndDate: range.customEndDate, appType: filters.appType, providerName: filters.providerName, model: filters.model, statusCode: filters.statusCode, - startDate: timeMode === "fixed" ? filters.startDate : undefined, - endDate: timeMode === "fixed" ? filters.endDate : undefined, }; return useQuery({ queryKey: usageKeys.logs(key, page, pageSize), queryFn: () => { - const effectiveFilters = - timeMode === "rolling" - ? { ...filters, ...getRollingRange(rollingWindowSeconds) } - : filters; + const effectiveFilters = { ...filters, ...resolveUsageRange(range) }; return usageApi.getRequestLogs(effectiveFilters, page, pageSize); }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新 diff --git a/src/types/usage.ts b/src/types/usage.ts index ca9b40a09..c595c1cf7 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -121,12 +121,18 @@ export interface ProviderLimitStatus { monthlyExceeded: boolean; } -export type TimeRange = "1d" | "7d" | "30d"; +export type UsageRangePreset = "today" | "1d" | "7d" | "14d" | "30d" | "custom"; + +export interface UsageRangeSelection { + preset: UsageRangePreset; + customStartDate?: number; + customEndDate?: number; +} export type AppTypeFilter = "all" | "claude" | "codex" | "gemini"; export interface StatsFilters { - timeRange: TimeRange; + timeRange: UsageRangePreset; providerId?: string; appType?: string; }