diff --git a/src-tauri/src/database/dao/usage_rollup.rs b/src-tauri/src/database/dao/usage_rollup.rs index fc5fdb371..d4b7b1958 100644 --- a/src-tauri/src/database/dao/usage_rollup.rs +++ b/src-tauri/src/database/dao/usage_rollup.rs @@ -89,6 +89,9 @@ impl Database { log::info!( "Rolled up and pruned {deleted} proxy_request_logs (retain={retain_days}d)" ); + // 归档触发了表结构变化,前端 30 天前的统计可能跟着变, + // 通知一次让 UsageDashboard 重拉数据 + crate::usage_events::notify_log_recorded(); } Ok(deleted) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cc60bf6bf..a332f7dcf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,6 +33,7 @@ mod settings; mod store; mod tray; +mod usage_events; mod usage_script; pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps}; @@ -336,6 +337,11 @@ pub fn run() { )?; } + // 注入 AppHandle 给 usage_events,让无 AppHandle 持有的写日志路径 + // 也能向前端推送 `usage-log-recorded`。 + // 放在日志系统初始化之后,确保 init 的日志能正常输出。 + usage_events::init(app.handle().clone()); + // 初始化数据库 let app_config_dir = crate::config::get_app_config_dir(); let db_path = app_config_dir.join("cc-switch.db"); diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index 940f08839..603c2bf17 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -102,6 +102,9 @@ impl<'a> UsageLogger<'a> { ) .map_err(|e| AppError::Database(format!("记录请求日志失败: {e}")))?; + // 通知前端使用统计有更新(200ms 防抖合并,不阻塞写入路径) + crate::usage_events::notify_log_recorded(); + Ok(()) } diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs index 27f71700a..fae5a8e50 100644 --- a/src-tauri/src/services/session_usage.rs +++ b/src-tauri/src/services/session_usage.rs @@ -441,42 +441,48 @@ fn insert_session_log_entry( ), }; - conn.execute( - "INSERT OR IGNORE INTO proxy_request_logs ( + let inserted_rows = conn + .execute( + "INSERT OR IGNORE INTO proxy_request_logs ( request_id, provider_id, app_type, model, request_model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, latency_ms, first_token_ms, status_code, error_message, session_id, provider_type, is_streaming, cost_multiplier, created_at, data_source ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", - rusqlite::params![ - request_id, - "_session", // provider_id: 标记为会话来源 - "claude", // app_type - msg.model, - msg.model, // request_model = model - msg.input_tokens, - msg.output_tokens, - msg.cache_read_tokens, - msg.cache_creation_tokens, - input_cost, - output_cost, - cache_read_cost, - cache_creation_cost, - total_cost, - 0i64, // latency_ms: 会话日志无此数据 - Option::::None, // first_token_ms - 200i64, // status_code: 有 stop_reason 说明请求成功 - Option::::None, // error_message - msg.session_id, - Some("session_log"), // provider_type - 1i64, // is_streaming: Claude Code 通常使用流式 - "1.0", // cost_multiplier - created_at, - "session_log", // data_source - ], - ) - .map_err(|e| AppError::Database(format!("插入会话日志失败: {e}")))?; + rusqlite::params![ + request_id, + "_session", // provider_id: 标记为会话来源 + "claude", // app_type + msg.model, + msg.model, // request_model = model + msg.input_tokens, + msg.output_tokens, + msg.cache_read_tokens, + msg.cache_creation_tokens, + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost, + total_cost, + 0i64, // latency_ms: 会话日志无此数据 + Option::::None, // first_token_ms + 200i64, // status_code: 有 stop_reason 说明请求成功 + Option::::None, // error_message + msg.session_id, + Some("session_log"), // provider_type + 1i64, // is_streaming: Claude Code 通常使用流式 + "1.0", // cost_multiplier + created_at, + "session_log", // data_source + ], + ) + .map_err(|e| AppError::Database(format!("插入会话日志失败: {e}")))?; + + // 仅在确实写入新行时通知前端,避免 INSERT OR IGNORE 跳过时产生空刷新 + if inserted_rows > 0 { + crate::usage_events::notify_log_recorded(); + } Ok(true) } diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index ff00ede2c..f23be19c3 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -75,9 +75,10 @@ fn normalize_codex_model(raw: &str) -> String { } // Step 3: 剥离 ISO 日期后缀 -YYYY-MM-DD(正好 11 字符) - if name.len() > 11 { + if name.len() > 11 && name.is_char_boundary(name.len() - 11) { let suffix = &name[name.len() - 11..]; - if suffix.as_bytes()[0] == b'-' + if suffix.is_ascii() + && suffix.as_bytes()[0] == b'-' && suffix[1..5].chars().all(|c| c.is_ascii_digit()) && suffix.as_bytes()[5] == b'-' && suffix[6..8].chars().all(|c| c.is_ascii_digit()) @@ -495,42 +496,47 @@ fn insert_codex_session_entry( ), }; - conn.execute( - "INSERT OR IGNORE INTO proxy_request_logs ( + let inserted_rows = conn + .execute( + "INSERT OR IGNORE INTO proxy_request_logs ( request_id, provider_id, app_type, model, request_model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, latency_ms, first_token_ms, status_code, error_message, session_id, provider_type, is_streaming, cost_multiplier, created_at, data_source ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", - rusqlite::params![ - request_id, - "_codex_session", // provider_id - "codex", // app_type - model, - model, // request_model = model - delta.input, - delta.output, - delta.cached_input, - 0i64, // cache_creation_tokens: Codex 日志无此数据 - input_cost, - output_cost, - cache_read_cost, - cache_creation_cost, - total_cost, - 0i64, // latency_ms - Option::::None, // first_token_ms - 200i64, // status_code - Option::::None, // error_message - session_id.map(|s| s.to_string()), - Some("codex_session"), // provider_type - 1i64, // is_streaming - "1.0", // cost_multiplier - created_at, - "codex_session", // data_source - ], - ) - .map_err(|e| AppError::Database(format!("插入 Codex 会话日志失败: {e}")))?; + rusqlite::params![ + request_id, + "_codex_session", // provider_id + "codex", // app_type + model, + model, // request_model = model + delta.input, + delta.output, + delta.cached_input, + 0i64, // cache_creation_tokens: Codex 日志无此数据 + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost, + total_cost, + 0i64, // latency_ms + Option::::None, // first_token_ms + 200i64, // status_code + Option::::None, // error_message + session_id.map(|s| s.to_string()), + Some("codex_session"), // provider_type + 1i64, // is_streaming + "1.0", // cost_multiplier + created_at, + "codex_session", // data_source + ], + ) + .map_err(|e| AppError::Database(format!("插入 Codex 会话日志失败: {e}")))?; + + if inserted_rows > 0 { + crate::usage_events::notify_log_recorded(); + } Ok(true) } diff --git a/src-tauri/src/services/session_usage_gemini.rs b/src-tauri/src/services/session_usage_gemini.rs index 97abdb23c..c904726f2 100644 --- a/src-tauri/src/services/session_usage_gemini.rs +++ b/src-tauri/src/services/session_usage_gemini.rs @@ -350,7 +350,11 @@ fn insert_gemini_session_entry( .map_err(|e| AppError::Database(format!("插入 Gemini 会话日志失败: {e}")))?; // changes() > 0 表示新插入或已更新,== 0 表示值完全相同(无实际变更) - Ok(conn.changes() > 0) + let changed = conn.changes() > 0; + if changed { + crate::usage_events::notify_log_recorded(); + } + Ok(changed) } /// 查找 Gemini 模型定价 diff --git a/src-tauri/src/usage_events.rs b/src-tauri/src/usage_events.rs new file mode 100644 index 000000000..d5487bfe2 --- /dev/null +++ b/src-tauri/src/usage_events.rs @@ -0,0 +1,68 @@ +//! 使用统计实时刷新事件模块 +//! +//! 当 `proxy_request_logs` 表写入新数据时(代理日志、会话同步、归档等), +//! 通过本模块向前端 emit `usage-log-recorded` 事件,让 UsageDashboard +//! 立刻 invalidate 查询缓存而无需等待轮询周期。 +//! +//! 设计要点: +//! - 全局单例 AppHandle:写日志路径上不持有 AppHandle,用 OnceCell 共享。 +//! - 200ms 防抖合并:流式响应等场景在短时间内可能写入多条日志, +//! 合并成一次事件可避免前端连续 invalidate。 +//! - 不阻塞写入:通知失败仅记录 warn 日志,不向上传播错误。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use std::time::Duration; + +use tauri::{AppHandle, Emitter}; + +/// 前端监听的事件名 +pub const EVENT_USAGE_LOG_RECORDED: &str = "usage-log-recorded"; + +/// 防抖窗口:合并 200ms 内的多次通知。 +const DEBOUNCE_WINDOW: Duration = Duration::from_millis(200); + +static APP_HANDLE: OnceLock = OnceLock::new(); + +/// 防抖标记:true 表示已有调度任务在等待 emit,后续通知合并到该任务。 +static EMIT_SCHEDULED: AtomicBool = AtomicBool::new(false); + +/// 在应用 setup 阶段调用一次,注入 AppHandle。 +/// +/// 重复调用是无害的(OnceLock 仅首次写入生效),但应用启动期只该被 +/// `lib.rs::run` 调一次。 +pub fn init(handle: AppHandle) { + if APP_HANDLE.set(handle).is_err() { + log::debug!("usage_events::init 重复调用,已忽略"); + } else { + log::info!("[usage-event] AppHandle 已注入,事件推送启用"); + } +} + +/// 通知前端有新的使用日志写入。 +/// +/// 调用方**不**需要持有 AppHandle,可以从任意线程/任意写入路径调用。 +/// 内部 200ms 防抖合并,绝不阻塞调用线程。 +pub fn notify_log_recorded() { + // AppHandle 未注入(典型出现在单元测试或 setup 之前):直接放弃。 + let Some(handle) = APP_HANDLE.get() else { + return; + }; + + // 已有调度任务:本次通知被合并到既有任务里,无需再起线程。 + if EMIT_SCHEDULED.swap(true, Ordering::AcqRel) { + return; + } + + let handle = handle.clone(); + std::thread::spawn(move || { + std::thread::sleep(DEBOUNCE_WINDOW); + // 必须先清标志再 emit:万一 emit 期间又有新通知进来, + // 下一轮防抖窗口会重新调度,不会丢失。 + EMIT_SCHEDULED.store(false, Ordering::Release); + + if let Err(e) = handle.emit(EVENT_USAGE_LOG_RECORDED, ()) { + log::warn!("emit {EVENT_USAGE_LOG_RECORDED} 失败: {e}"); + } + }); +} diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index f9de1cf60..844777ce7 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -21,6 +21,7 @@ import { import { Button } from "@/components/ui/button"; import { useQueryClient } from "@tanstack/react-query"; import { usageKeys } from "@/lib/query/usage"; +import { useUsageEventBridge } from "@/hooks/useUsageEventBridge"; import { Accordion, AccordionContent, @@ -43,6 +44,10 @@ export function UsageDashboard() { const [appType, setAppType] = useState("all"); const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000); + // 后端写入新日志时 emit `usage-log-recorded`,本 hook 立刻 invalidate 所有 + // usage 查询,实现实时刷新(仅在 Dashboard 挂载时生效,离开页面自动取消监听) + useUsageEventBridge(); + const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const; const changeRefreshInterval = () => { const currentIndex = refreshIntervalOptionsMs.indexOf( diff --git a/src/hooks/useUsageEventBridge.ts b/src/hooks/useUsageEventBridge.ts new file mode 100644 index 000000000..f2d23f668 --- /dev/null +++ b/src/hooks/useUsageEventBridge.ts @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { useQueryClient } from "@tanstack/react-query"; +import { usageKeys } from "@/lib/query/usage"; + +/** + * 监听后端 `usage-log-recorded` 事件,收到后立刻 invalidate 所有 + * UsageDashboard 相关查询,让用户无需等待 30s 轮询周期。 + * + * 后端在 `proxy_request_logs` 写入新行时会 emit 该事件(200ms 防抖合并), + * 来源覆盖代理日志、Claude/Codex/Gemini 会话同步、启动归档。 + * + * 该 hook 只挂在 UsageDashboard 上,避免在主界面其他位置无意义触发。 + */ +export function useUsageEventBridge() { + const queryClient = useQueryClient(); + + useEffect(() => { + let unlisten: UnlistenFn | undefined; + let disposed = false; + + (async () => { + const off = await listen("usage-log-recorded", () => { + // invalidate 整个 usage 命名空间:summary / trends / providerStats / + // modelStats / logs 全部跟着重拉 + queryClient.invalidateQueries({ queryKey: usageKeys.all }); + }); + + if (disposed) { + off(); + } else { + unlisten = off; + } + })(); + + return () => { + disposed = true; + unlisten?.(); + }; + }, [queryClient]); +}