From dcd6b42a665ca0c955eeaedc55be8fbaa4dcae43 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sun, 10 Aug 2025 01:46:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/usage.rs | 109 ++-- src-tauri/src/commands/usage_index.rs | 353 ++++++++++++ src-tauri/src/main.rs | 12 + src/components/TokenUsageTrend.tsx | 218 +++++++ src/components/UsageDashboard.tsx | 782 +++++++++++++++++++++----- src/lib/api.ts | 6 + src/lib/usage-index.ts | 38 ++ src/locales/en/common.json | 11 + src/locales/zh/common.json | 11 + 10 files changed, 1382 insertions(+), 159 deletions(-) create mode 100644 src-tauri/src/commands/usage_index.rs create mode 100644 src/components/TokenUsageTrend.tsx create mode 100644 src/lib/usage-index.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index bfda7e8..b1f7d31 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod agents; pub mod claude; pub mod mcp; pub mod usage; +pub mod usage_index; pub mod storage; pub mod slash_commands; pub mod proxy; diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 8e9d833..75e08db 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -50,6 +50,12 @@ pub struct DailyUsage { date: String, total_cost: f64, total_tokens: u64, + // New detailed per-day breakdowns + input_tokens: u64, + output_tokens: u64, + cache_creation_tokens: u64, + cache_read_tokens: u64, + request_count: u64, models_used: Vec, } @@ -382,12 +388,15 @@ pub fn get_usage_stats(days: Option) -> Result { // Filter by days if specified let filtered_entries = if let Some(days) = days { - let cutoff = Local::now().naive_local().date() - chrono::Duration::days(days as i64); + // Convert 'now' to local date for consistent comparison + let cutoff = Local::now().with_timezone(&Local).date_naive() - chrono::Duration::days(days as i64); all_entries .into_iter() .filter(|e| { if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) { - dt.naive_local().date() >= cutoff + // Convert each entry timestamp to local time, then compare dates + let local_date = dt.with_timezone(&Local).date_naive(); + local_date >= cutoff } else { false } @@ -450,24 +459,39 @@ pub fn get_usage_stats(days: Option) -> Result { .or_insert_with(HashSet::new) .insert(entry.session_id.clone()); - // Update daily stats - let date = entry - .timestamp - .split('T') - .next() - .unwrap_or(&entry.timestamp) - .to_string(); + // Update daily stats (use local timezone date) + let date = if let Ok(dt) = DateTime::parse_from_rfc3339(&entry.timestamp) { + dt.with_timezone(&Local).date_naive().to_string() + } else { + // Fallback to raw prefix if parse fails + entry + .timestamp + .split('T') + .next() + .unwrap_or(&entry.timestamp) + .to_string() + }; let daily_stat = daily_stats.entry(date.clone()).or_insert(DailyUsage { date, total_cost: 0.0, total_tokens: 0, + input_tokens: 0, + output_tokens: 0, + cache_creation_tokens: 0, + cache_read_tokens: 0, + request_count: 0, models_used: vec![], }); daily_stat.total_cost += entry.cost; - daily_stat.total_tokens += entry.input_tokens - + entry.output_tokens - + entry.cache_creation_tokens - + entry.cache_read_tokens; + daily_stat.input_tokens += entry.input_tokens; + daily_stat.output_tokens += entry.output_tokens; + daily_stat.cache_creation_tokens += entry.cache_creation_tokens; + daily_stat.cache_read_tokens += entry.cache_read_tokens; + daily_stat.total_tokens = daily_stat.input_tokens + + daily_stat.output_tokens + + daily_stat.cache_creation_tokens + + daily_stat.cache_read_tokens; + daily_stat.request_count += 1; if !daily_stat.models_used.contains(&entry.model) { daily_stat.models_used.push(entry.model.clone()); } @@ -559,15 +583,15 @@ pub fn get_usage_by_date_range(start_date: String, end_date: String) -> Result Result= start && date <= end } else { false @@ -652,24 +676,38 @@ pub fn get_usage_by_date_range(start_date: String, end_date: String) -> Result= s); let is_before_until = until_date.map_or(true, |u| date <= u); is_after_since && is_before_until diff --git a/src-tauri/src/commands/usage_index.rs b/src-tauri/src/commands/usage_index.rs new file mode 100644 index 0000000..14fc802 --- /dev/null +++ b/src-tauri/src/commands/usage_index.rs @@ -0,0 +1,353 @@ +use chrono::Utc; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tauri::State; +use walkdir::WalkDir; + +#[derive(Default)] +pub struct UsageIndexState { + pub jobs: Arc>>, // job_id -> progress +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScanProgress { + pub processed: u64, + pub total: u64, + pub started_ts: i64, + pub finished_ts: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageSummary { + pub files: u64, + pub tokens: u64, + pub lines: u64, + pub last_scan_ts: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportResult { pub inserted: u64, pub skipped: u64, pub errors: u64 } + +fn db_path_for(project_root: &Path) -> PathBuf { + project_root.join(".claudia/cache/usage.sqlite") +} + +fn ensure_parent_dir(p: &Path) -> std::io::Result<()> { + if let Some(dir) = p.parent() { std::fs::create_dir_all(dir)?; } + Ok(()) +} + +fn open_db(project_root: &Path) -> rusqlite::Result { + let path = db_path_for(project_root); + ensure_parent_dir(&path).map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let conn = Connection::open(path)?; + conn.pragma_update(None, "journal_mode", &"WAL")?; + // schema + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY); + INSERT OR IGNORE INTO schema_version(version) VALUES (1); + + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_root TEXT NOT NULL, + rel_path TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + mtime_ms INTEGER NOT NULL, + sha256 TEXT NOT NULL, + language TEXT, + UNIQUE(project_root, rel_path) + ); + CREATE INDEX IF NOT EXISTS idx_files_project_path ON files(project_root, rel_path); + + CREATE TABLE IF NOT EXISTS file_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL, + snapshot_ts INTEGER NOT NULL, + lines INTEGER, + tokens INTEGER, + chars INTEGER, + FOREIGN KEY(file_id) REFERENCES files(id) + ); + CREATE INDEX IF NOT EXISTS idx_metrics_file_ts ON file_metrics(file_id, snapshot_ts); + + CREATE TABLE IF NOT EXISTS file_diffs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL, + snapshot_ts INTEGER NOT NULL, + prev_snapshot_ts INTEGER, + added_lines INTEGER, + removed_lines INTEGER, + added_tokens INTEGER, + removed_tokens INTEGER, + change_type TEXT CHECK(change_type IN('created','modified','deleted')) NOT NULL, + FOREIGN KEY(file_id) REFERENCES files(id) + ); + CREATE INDEX IF NOT EXISTS idx_diffs_file_ts ON file_diffs(file_id, snapshot_ts); + "#, + )?; + Ok(conn) +} + +fn sha256_file(path: &Path) -> std::io::Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 8192]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { break; } + hasher.update(&buf[..n]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn count_lines_chars_tokens(path: &Path) -> std::io::Result<(u64, u64, u64)> { + let f = File::open(path)?; + let reader = BufReader::new(f); + let mut lines = 0u64; + let mut chars = 0u64; + let mut tokens = 0u64; + for line in reader.lines() { + let l = line?; + lines += 1; + chars += l.len() as u64; + tokens += l.split_whitespace().count() as u64; + } + Ok((lines, chars, tokens)) +} + +fn should_exclude(rel: &str, excludes: &HashSet) -> bool { + // simple prefix/segment check + let default = ["node_modules/", "dist/", "target/", ".git/" ]; + if default.iter().any(|p| rel.starts_with(p)) { return true; } + if rel.ends_with(".lock") { return true; } + excludes.iter().any(|p| rel.starts_with(p)) +} + +#[tauri::command] +pub async fn usage_scan_index( + project_root: String, + exclude: Option>, + state: State<'_, UsageIndexState>, +) -> Result { + let project = PathBuf::from(project_root.clone()); + if !project.is_dir() { return Err("project_root is not a directory".into()); } + let job_id = uuid::Uuid::new_v4().to_string(); + { + let mut jobs = state.jobs.lock().map_err(|e| e.to_string())?; + jobs.insert(job_id.clone(), ScanProgress{ processed:0, total:0, started_ts: Utc::now().timestamp_millis(), finished_ts: None}); + } + let excludes: HashSet = exclude.unwrap_or_default().into_iter().collect(); + let state_jobs = state.jobs.clone(); + let job_id_task = job_id.clone(); + let job_id_ret = job_id.clone(); + tauri::async_runtime::spawn(async move { + let mut conn = match open_db(&project) { Ok(c)=>c, Err(e)=>{ log::error!("DB open error: {}", e); return; } }; + // First pass: count total + let mut total: u64 = 0; + for entry in WalkDir::new(&project).into_iter().filter_map(Result::ok) { + if entry.file_type().is_file() { + if let Ok(rel) = entry.path().strip_prefix(&project) { + let rel = rel.to_string_lossy().replace('\\',"/"); + if should_exclude(&format!("{}/", rel).trim_end_matches('/'), &excludes) { continue; } + total += 1; + } + } + } + { + if let Ok(mut jobs) = state_jobs.lock() { if let Some(p) = jobs.get_mut(&job_id_task){ p.total = total; } } + } + // Cache existing file meta + let mut existing: HashMap = HashMap::new(); // rel -> (size, mtime, sha, file_id) + { + let stmt = conn.prepare("SELECT id, rel_path, size_bytes, mtime_ms, sha256 FROM files WHERE project_root=?1").ok(); + if let Some(mut st) = stmt { + let rows = st.query_map(params![project.to_string_lossy()], |row| { + let id: i64 = row.get(0)?; + let rel: String = row.get(1)?; + let size: i64 = row.get(2)?; + let mtime: i64 = row.get(3)?; + let sha: String = row.get(4)?; + Ok((rel, (size, mtime, sha, id))) + }); + if let Ok(rows) = rows { for r in rows.flatten(){ existing.insert(r.0, r.1); } } + } + } + + let mut seen: HashSet = HashSet::new(); + let now = Utc::now().timestamp_millis(); + let tx = conn.transaction(); + let mut processed: u64 = 0; + if let Ok(tx) = tx { + for entry in WalkDir::new(&project).into_iter().filter_map(Result::ok) { + if entry.file_type().is_file() { + if let Ok(relp) = entry.path().strip_prefix(&project) { + let rel = relp.to_string_lossy().replace('\\',"/"); + let rel_norm = rel.clone(); + if should_exclude(&format!("{}/", rel_norm).trim_end_matches('/'), &excludes) { continue; } + let md = match entry.metadata() { Ok(m)=>m, Err(_)=>{ continue } }; + let size = md.len() as i64; + let mtime = md.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as i64).unwrap_or(0); + let mut content_changed = true; + let sha: String; + if let Some((esize, emtime, esha, _fid)) = existing.get(&rel_norm) { + if *esize == size && *emtime == mtime { content_changed = false; sha = esha.clone(); } + else { sha = sha256_file(entry.path()).unwrap_or_default(); if sha == *esha { content_changed = false; } } + } else { + sha = sha256_file(entry.path()).unwrap_or_default(); + } + + // upsert files + tx.execute( + "INSERT INTO files(project_root, rel_path, size_bytes, mtime_ms, sha256, language) VALUES (?1,?2,?3,?4,?5,NULL) + ON CONFLICT(project_root, rel_path) DO UPDATE SET size_bytes=excluded.size_bytes, mtime_ms=excluded.mtime_ms, sha256=excluded.sha256", + params![project.to_string_lossy(), rel_norm, size, mtime, sha], + ).ok(); + + // get file_id + let file_id: i64 = tx.query_row( + "SELECT id FROM files WHERE project_root=?1 AND rel_path=?2", + params![project.to_string_lossy(), rel_norm], |row| row.get(0) + ).unwrap_or(-1); + + // metrics + if content_changed { + if let Ok((lines, chars, tokens)) = count_lines_chars_tokens(entry.path()) { + tx.execute( + "INSERT INTO file_metrics(file_id, snapshot_ts, lines, tokens, chars) VALUES (?1,?2,?3,?4,?5)", + params![file_id, now, lines as i64, tokens as i64, chars as i64] + ).ok(); + // diff + let prev: Option<(i64,i64,i64)> = tx.query_row( + "SELECT lines, tokens, snapshot_ts FROM file_metrics WHERE file_id=?1 ORDER BY snapshot_ts DESC LIMIT 1 OFFSET 1", + params![file_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + ).ok(); + let (added_l, removed_l, added_t, removed_t, prev_ts, change_type) = match prev { + None => (lines as i64, 0, tokens as i64, 0, None, "created".to_string()), + Some((pl, pt, pts)) => { + let dl = lines as i64 - pl; let dt = tokens as i64 - pt; + (dl.max(0), (-dl).max(0), dt.max(0), (-dt).max(0), Some(pts), "modified".to_string()) + } + }; + tx.execute( + "INSERT INTO file_diffs(file_id, snapshot_ts, prev_snapshot_ts, added_lines, removed_lines, added_tokens, removed_tokens, change_type) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + params![file_id, now, prev_ts, added_l, removed_l, added_t, removed_t, change_type] + ).ok(); + } + } + seen.insert(rel_norm); + processed += 1; + if let Ok(mut jobs) = state_jobs.lock() { if let Some(p) = jobs.get_mut(&job_id_task){ p.processed = processed; } } + } + } + } + + // deletions: files in DB but not seen + let mut to_delete: Vec<(i64,i64,i64)> = Vec::new(); // (file_id, last_lines, last_tokens) + { + let stmt = tx.prepare("SELECT f.id, m.lines, m.tokens FROM files f LEFT JOIN file_metrics m ON m.file_id=f.id WHERE f.project_root=?1 AND m.snapshot_ts=(SELECT MAX(snapshot_ts) FROM file_metrics WHERE file_id=f.id)").ok(); + if let Some(mut st) = stmt { + let rows = st.query_map(params![project.to_string_lossy()], |row| Ok((row.get(0)?, row.get::<_,Option>(1).unwrap_or(None).unwrap_or(0), row.get::<_,Option>(2).unwrap_or(None).unwrap_or(0)))) ; + if let Ok(rows) = rows { for r in rows.flatten() { to_delete.push(r); } } + } + } + for (fid, last_lines, last_tokens) in to_delete { + let rel: String = tx.query_row("SELECT rel_path FROM files WHERE id=?1", params![fid], |r| r.get(0)).unwrap_or_default(); + if !seen.contains(&rel) { + tx.execute( + "INSERT INTO file_diffs(file_id, snapshot_ts, prev_snapshot_ts, added_lines, removed_lines, added_tokens, removed_tokens, change_type) VALUES (?1,?2,NULL,0,?3,0,?4,'deleted')", + params![fid, now, last_lines, last_tokens] + ).ok(); + } + } + + tx.commit().ok(); + } + + if let Ok(mut jobs) = state_jobs.lock() { if let Some(p) = jobs.get_mut(&job_id_task){ p.finished_ts = Some(Utc::now().timestamp_millis()); } } + }); + + Ok(job_id_ret) +} + +#[tauri::command] +pub fn usage_scan_progress(job_id: String, state: State<'_, UsageIndexState>) -> Result { + let jobs = state.jobs.lock().map_err(|e| e.to_string())?; + jobs.get(&job_id).cloned().ok_or_else(|| "job not found".into()) +} + +#[tauri::command] +pub fn usage_get_summary(project_root: String) -> Result { + let project = PathBuf::from(project_root); + let conn = open_db(&project).map_err(|e| e.to_string())?; + let files: u64 = conn.query_row("SELECT COUNT(*) FROM files WHERE project_root=?1", params![project.to_string_lossy()], |r| r.get::<_,i64>(0)).unwrap_or(0) as u64; + let mut lines: u64 = 0; let mut tokens: u64 = 0; let mut last_ts: Option = None; + let mut stmt = conn.prepare("SELECT MAX(snapshot_ts), SUM(lines), SUM(tokens) FROM file_metrics WHERE file_id IN (SELECT id FROM files WHERE project_root=?1)").map_err(|e| e.to_string())?; + let res = stmt.query_row(params![project.to_string_lossy()], |r| { + Ok((r.get::<_,Option>(0)?, r.get::<_,Option>(1)?, r.get::<_,Option>(2)?)) + }); + if let Ok((mx, lsum, tsum)) = res { last_ts = mx; lines = lsum.unwrap_or(0) as u64; tokens = tsum.unwrap_or(0) as u64; } + Ok(UsageSummary{ files, tokens, lines, last_scan_ts: last_ts }) +} + +#[derive(Debug, Deserialize)] +struct ExternalDiff { + rel_path: String, + snapshot_ts: i64, + #[serde(default)] prev_snapshot_ts: Option, + #[serde(default)] added_lines: i64, + #[serde(default)] removed_lines: i64, + #[serde(default)] added_tokens: i64, + #[serde(default)] removed_tokens: i64, + change_type: String, +} + +#[tauri::command] +pub fn usage_import_diffs(project_root: String, path: String) -> Result { + let project = PathBuf::from(project_root); + let mut conn = open_db(&project).map_err(|e| e.to_string())?; + let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let mut inserted=0u64; let mut skipped=0u64; let mut errors=0u64; + let tx = conn.transaction().map_err(|e| e.to_string())?; + // try as JSON array + let mut diffs: Vec = Vec::new(); + match serde_json::from_str::(&data) { + Ok(serde_json::Value::Array(arr)) => { + for v in arr { if let Ok(d) = serde_json::from_value::(v) { diffs.push(d); } } + }, + _ => { + // try NDJSON + for line in data.lines() { + let l = line.trim(); if l.is_empty() { continue; } + match serde_json::from_str::(l) { Ok(d)=>diffs.push(d), Err(_)=>{ errors+=1; } } + } + } + } + for d in diffs { + // ensure file exists in files table (create placeholder if missing) + tx.execute( + "INSERT INTO files(project_root, rel_path, size_bytes, mtime_ms, sha256, language) VALUES (?1,?2,0,0,'',NULL) + ON CONFLICT(project_root, rel_path) DO NOTHING", + params![project.to_string_lossy(), d.rel_path], + ).ok(); + let file_id: Option = tx.query_row( + "SELECT id FROM files WHERE project_root=?1 AND rel_path=?2", + params![project.to_string_lossy(), d.rel_path], |r| r.get(0) + ).ok(); + if let Some(fid) = file_id { + let res = tx.execute( + "INSERT INTO file_diffs(file_id, snapshot_ts, prev_snapshot_ts, added_lines, removed_lines, added_tokens, removed_tokens, change_type) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + params![fid, d.snapshot_ts, d.prev_snapshot_ts, d.added_lines, d.removed_lines, d.added_tokens, d.removed_tokens, d.change_type] + ); + if res.is_ok() { inserted+=1; } else { skipped+=1; } + } else { errors+=1; } + } + tx.commit().map_err(|e| e.to_string())?; + Ok(ImportResult{ inserted, skipped, errors }) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 18da692..b395c61 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -40,6 +40,9 @@ use commands::mcp::{ use commands::usage::{ get_session_stats, get_usage_by_date_range, get_usage_details, get_usage_stats, }; +use commands::usage_index::{ + usage_get_summary, usage_import_diffs, usage_scan_index, usage_scan_progress, UsageIndexState, +}; use commands::storage::{ storage_list_tables, storage_read_table, storage_update_row, storage_delete_row, storage_insert_row, storage_execute_sql, storage_reset_database, @@ -160,6 +163,9 @@ fn main() { // Initialize Claude process state app.manage(ClaudeProcessState::default()); + // Initialize Usage Index state + app.manage(UsageIndexState::default()); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -240,6 +246,12 @@ fn main() { get_usage_by_date_range, get_usage_details, get_session_stats, + + // File Usage Index (SQLite) + usage_scan_index, + usage_scan_progress, + usage_get_summary, + usage_import_diffs, // MCP (Model Context Protocol) mcp_add, diff --git a/src/components/TokenUsageTrend.tsx b/src/components/TokenUsageTrend.tsx new file mode 100644 index 0000000..e9ce2da --- /dev/null +++ b/src/components/TokenUsageTrend.tsx @@ -0,0 +1,218 @@ +import React, { useMemo, useState } from "react"; +import { Card } from "@/components/ui/card"; +import type { DailyUsage } from "@/lib/api"; + +interface TokenUsageTrendProps { + days: DailyUsage[]; +} + +// Simple number formatters +const fmtTokens = (n: number) => { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return `${n}`; +}; +const fmtUSD = (n: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 4 }).format(n); + +/** + * A lightweight multi-series line/area chart implemented with SVG and basic UI primitives. + * - Left axis: Tokens (input/output/cache write/cache read) + * - Right axis: Cost (USD) and Requests count (normalized to its own max) + * - Tooltip closely matches the screenshot content + */ +export const TokenUsageTrend: React.FC = ({ days }) => { + const [hoverIndex, setHoverIndex] = useState(null); + + const { labels, series, maxTokens, maxCost, maxReq } = useMemo(() => { + const sorted = days.slice().reverse(); // chronological left->right + const labels = sorted.map((d) => + new Date(d.date.replace(/-/g, "/")).toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }) + ); + const series = { + input: sorted.map((d) => d.input_tokens || 0), + output: sorted.map((d) => d.output_tokens || 0), + cacheW: sorted.map((d) => d.cache_creation_tokens || 0), + cacheR: sorted.map((d) => d.cache_read_tokens || 0), + cost: sorted.map((d) => d.total_cost || 0), + reqs: sorted.map((d) => d.request_count || 0), + sumTokens: sorted.map( + (d) => (d.input_tokens || 0) + (d.output_tokens || 0) + (d.cache_creation_tokens || 0) + (d.cache_read_tokens || 0) + ), + } as const; + const maxTokens = Math.max(1, ...series.sumTokens, ...series.input, ...series.output, ...series.cacheW, ...series.cacheR); + const maxCost = Math.max(1, ...series.cost); + const maxReq = Math.max(1, ...series.reqs); + return { labels, series, maxTokens, maxCost, maxReq }; + }, [days]); + + const width = 900; + const height = 260; + const padL = 56; // room for left ticks + const padR = 56; // room for right ticks + const padT = 16; + const padB = 36; + const plotW = width - padL - padR; + const plotH = height - padT - padB; + + const n = labels.length; + const x = (i: number) => padL + (plotW * i) / Math.max(1, n - 1); + const yToken = (v: number) => padT + plotH * (1 - v / maxTokens); + const yCost = (v: number) => padT + plotH * (1 - v / maxCost); + const yReq = (v: number) => padT + plotH * (1 - v / maxReq); + + const pathFrom = (vals: number[], y: (v: number) => number) => + vals.map((v, i) => `${i === 0 ? "M" : "L"} ${x(i)} ${y(v)}`).join(" "); + + const colors = { + input: "#3b82f6", // blue-500 + output: "#ec4899", // pink-500 + cacheW: "#60a5fa", // blue-400 + cacheR: "#a78bfa", // violet-400 + cost: "#22c55e", // green-500 + req: "#16a34a", // green-600 + grid: "var(--border)", + text: "var(--muted-foreground)", + } as const; + + const hovered = hoverIndex != null ? hoverIndex : null; + + const renderTooltip = () => { + if (hovered == null) return null; + const dateText = new Date(days.slice().reverse()[hovered].date.replace(/-/g, "/")).toLocaleDateString("zh-CN", { + month: "2-digit", + day: "2-digit", + }); + const d = days.slice().reverse()[hovered]; + return ( +
+
+
{dateText}
+
+
+ + 费用(USD):{fmtUSD(d.total_cost)} +
+
+ + 缓存读取Token: {fmtTokens(d.cache_read_tokens || 0)} tokens +
+
+ + 缓存创建Token: {fmtTokens(d.cache_creation_tokens || 0)} tokens +
+
+ + 输出Token: {fmtTokens(d.output_tokens || 0)} tokens +
+
+ + 输入Token: {fmtTokens(d.input_tokens || 0)} tokens +
+
+ + 请求数:{d.request_count || 0} 次 +
+
+
+
+ ); + }; + + return ( + +

Token使用趋势

+
+ + {/* axes */} + + + {/* left ticks (tokens) 0, 25%, 50%, 75%, 100% */} + {[0, 0.25, 0.5, 0.75, 1].map((t) => ( + + + {fmtTokens(Math.round(maxTokens * t))} + + + + ))} + {/* right ticks (cost/requests) */} + {[0, 0.5, 1].map((t) => ( + + + {t === 1 ? fmtUSD(maxCost) : t === 0.5 ? fmtUSD(maxCost / 2) : "$0"} + + + ))} + + {/* token lines */} + + + + + + {/* cost line (right axis) */} + + + {/* requests as small circles on right scale */} + {series.reqs.map((v, i) => ( + + ))} + + {/* x labels and hover hit-areas */} + {labels.map((lab, i) => ( + setHoverIndex(i)} + onMouseLeave={() => setHoverIndex(null)}> + + {lab} + + {/* vertical hover guide */} + {hoverIndex === i && ( + + )} + {/* invisible hit area */} + + + ))} + + {/* Tooltip container */} + {hoverIndex != null && ( +
+ {renderTooltip()} +
+ )} +
+ {/* legend */} +
+
输入Token
+
输出Token
+
缓存创建Token
+
缓存读取Token
+
费用(USD)
+
请求数
+
+
+ ); +}; + diff --git a/src/components/UsageDashboard.tsx b/src/components/UsageDashboard.tsx index 512c5a9..77ab05a 100644 --- a/src/components/UsageDashboard.tsx +++ b/src/components/UsageDashboard.tsx @@ -18,6 +18,23 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { useTranslation } from "@/hooks/useTranslation"; +import { + LineChart, + Line, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + ResponsiveContainer, + BarChart, + Bar, + PieChart, + Pie, + Cell, + Legend +} from "recharts"; interface UsageDashboardProps { /** @@ -293,6 +310,182 @@ export const UsageDashboard: React.FC = ({ onBack }) => { + {/* 使用趋势图表 - 整合了Token使用趋势 */} + {stats.by_date.length > 1 && ( + +

{t('usage.dailyUsageOverTime')}

+
+ + ({ + date: new Date(day.date.replace(/-/g, '/')).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric' + }), + cost: day.total_cost, + inputTokens: (day.input_tokens || 0) / 1000, // 转换为K + outputTokens: (day.output_tokens || 0) / 1000, + cacheWriteTokens: (day.cache_creation_tokens || 0) / 1000, + cacheReadTokens: (day.cache_read_tokens || 0) / 1000, + requests: day.request_count || 0, + }))} + margin={{ top: 5, right: 60, left: 20, bottom: 40 }} + > + + + `${value}K`} + label={{ value: 'Tokens (K)', angle: -90, position: 'insideLeft', style: { fontSize: 10 } }} + className="text-muted-foreground" + /> + `$${value.toFixed(2)}`} + label={{ value: 'Cost (USD)', angle: 90, position: 'insideRight', style: { fontSize: 10 } }} + className="text-muted-foreground" + /> + { + // 定义线条颜色映射 + const colorMap: Record = { + 'inputTokens': '#3b82f6', + 'outputTokens': '#ec4899', + 'cacheWriteTokens': '#60a5fa', + 'cacheReadTokens': '#a78bfa', + 'cost': '#22c55e', + 'requests': '#f59e0b' + }; + + // 获取翻译名称 + const nameMap: Record = { + 'inputTokens': t('usage.inputTokens'), + 'outputTokens': t('usage.outputTokens'), + 'cacheWriteTokens': t('usage.cacheWrite'), + 'cacheReadTokens': t('usage.cacheRead'), + 'cost': t('usage.cost'), + 'requests': t('usage.requests') + }; + + // 格式化值 + let formattedValue = value; + if (name === 'cost') { + formattedValue = formatCurrency(value); + } else if (name.includes('Tokens')) { + formattedValue = `${formatTokens(value * 1000)} tokens`; + } else if (name === 'requests') { + formattedValue = `${value} ${t('usage.times')}`; + } + + // 返回带颜色的格式化内容 + return [ + + {formattedValue} + , + nameMap[name] || name + ]; + }} + /> + { + const nameMap: Record = { + 'inputTokens': t('usage.inputTokens'), + 'outputTokens': t('usage.outputTokens'), + 'cacheWriteTokens': t('usage.cacheWrite'), + 'cacheReadTokens': t('usage.cacheRead'), + 'cost': t('usage.cost'), + 'requests': t('usage.requests') + }; + return nameMap[value] || value; + }} + /> + + {/* Token 线条 - 左轴 */} + + + + + + {/* 费用线条 - 右轴 */} + + + +
+
+ )} + {/* Quick Stats */}
@@ -341,81 +534,405 @@ export const UsageDashboard: React.FC = ({ onBack }) => { {/* Models Tab */} - -

{t('usage.usageByModel')}

-
- {stats.by_model.map((model) => ( -
-
-
- - {getModelDisplayName(model.model)} - - - {model.session_count} {t('usage.sessions')} +
+ {/* 饼图 */} + +

{t('usage.usageByModel')}

+
+ + + ({ + name: getModelDisplayName(model.model), + value: model.total_cost, + sessions: model.session_count, + tokens: model.total_tokens + }))} + cx="50%" + cy="50%" + labelLine={false} + label={({ percent }) => `${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {stats.by_model.map((_, index) => ( + + ))} + + { + if (name === 'value') { + return [ + formatCurrency(value), + `${props.payload.sessions} sessions, ${formatTokens(props.payload.tokens)} tokens` + ]; + } + return [value, name]; + }} + /> + + + +
+
+ + {/* 详细列表 */} + +

详细统计

+
+ {stats.by_model.map((model) => ( +
+
+
+ + {getModelDisplayName(model.model)} + + + {model.session_count} {t('usage.sessions')} + +
+ + {formatCurrency(model.total_cost)}
- - {formatCurrency(model.total_cost)} - -
-
-
- {t('usage.input')}: - {formatTokens(model.input_tokens)} -
-
- {t('usage.output')}: - {formatTokens(model.output_tokens)} -
-
- Cache W: - {formatTokens(model.cache_creation_tokens)} -
-
- Cache R: - {formatTokens(model.cache_read_tokens)} +
+
+ {t('usage.input')}: + {formatTokens(model.input_tokens)} +
+
+ {t('usage.output')}: + {formatTokens(model.output_tokens)} +
+
+ Cache W: + {formatTokens(model.cache_creation_tokens)} +
+
+ Cache R: + {formatTokens(model.cache_read_tokens)} +
-
- ))} -
-
+ ))} +
+ +
{/* Projects Tab */} - -

{t('usage.usageByProject')}

-
- {stats.by_project.map((project) => ( -
-
- - {project.project_path} - -
- - {project.session_count} {t('usage.sessions')} - - - {formatTokens(project.total_tokens)} {t('usage.tokens')} - -
-
-
-

{formatCurrency(project.total_cost)}

-

- {formatCurrency(project.total_cost / project.session_count)}/{t('usage.session')} +

+ {/* 顶部统计卡片 */} +
+ +
+
+

{t('usage.totalProjects')}

+

+ {stats.by_project.length}

+
- ))} +
+ +
+
+

{t('usage.avgProjectCost')}

+

+ {formatCurrency( + stats.by_project.length > 0 + ? stats.by_project.reduce((sum, p) => sum + p.total_cost, 0) / stats.by_project.length + : 0 + )} +

+
+ +
+
+ +
+
+

{t('usage.topProjectCost')}

+

+ {stats.by_project.length > 0 + ? formatCurrency(Math.max(...stats.by_project.map(p => p.total_cost))) + : '$0.00'} +

+
+ +
+
- + + {/* 图表区域 */} +
+ {/* 成本分布饼图 */} + +

{t('usage.projectCostDistribution')}

+ {stats.by_project.length > 0 ? ( +
+ + + ({ + name: project.project_path.split('/').slice(-2).join('/'), + value: project.total_cost, + sessions: project.session_count, + tokens: project.total_tokens, + fullPath: project.project_path + }))} + cx="50%" + cy="50%" + labelLine={false} + label={({ percent }) => `${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {stats.by_project.slice(0, 8).map((_, index) => ( + + ))} + + { + if (name === 'value') { + return [ + formatCurrency(value), + `${props.payload.sessions} ${t('usage.sessions')}, ${formatTokens(props.payload.tokens)} tokens` + ]; + } + return [value, name]; + }} + /> + + + +
+ ) : ( +
+ {t('usage.noProjectData')} +
+ )} +
+ + {/* Token使用柱状图 */} + +

{t('usage.projectTokenUsage')}

+ {stats.by_project.length > 0 ? ( +
+ + ({ + name: project.project_path.split('/').slice(-1)[0], + input: project.input_tokens / 1000, + output: project.output_tokens / 1000, + cacheWrite: project.cache_creation_tokens / 1000, + cacheRead: project.cache_read_tokens / 1000 + }))} + margin={{ top: 5, right: 30, left: 20, bottom: 60 }} + > + + + `${value}K`} + className="text-muted-foreground" + /> + `${formatTokens(value * 1000)} tokens`} + /> + { + const nameMap: Record = { + 'input': t('usage.inputTokens'), + 'output': t('usage.outputTokens'), + 'cacheWrite': t('usage.cacheWrite'), + 'cacheRead': t('usage.cacheRead') + }; + return nameMap[value] || value; + }} + /> + + + + + + +
+ ) : ( +
+ {t('usage.noProjectData')} +
+ )} +
+
+ + {/* 成本排行条形图 */} + +

{t('usage.projectCostRanking')}

+ {stats.by_project.length > 0 && ( +
+ + ({ + name: project.project_path.split('/').slice(-2).join('/'), + fullPath: project.project_path, + cost: project.total_cost, + sessions: project.session_count, + tokens: project.total_tokens + }))} + layout="horizontal" + margin={{ top: 5, right: 30, left: 100, bottom: 5 }} + > + + formatCurrency(value)} + className="text-muted-foreground" + /> + + { + if (name === 'cost') { + return [ + formatCurrency(value), + `${props.payload.sessions} ${t('usage.sessions')}, ${formatTokens(props.payload.tokens)} tokens` + ]; + } + return [value, name]; + }} + labelFormatter={(label) => `${t('usage.project')}: ${label}`} + /> + + + +
+ )} +
+ + {/* 详细列表 */} + +

{t('usage.projectDetails')}

+
+ {stats.by_project.map((project) => ( +
+
+ + {project.project_path} + +
+ + {project.session_count} {t('usage.sessions')} + + + {formatTokens(project.total_tokens)} {t('usage.tokens')} + +
+
+
+

{formatCurrency(project.total_cost)}

+

+ {formatCurrency(project.total_cost / project.session_count)}/{t('usage.session')} +

+
+
+ ))} +
+
+
{/* Sessions Tab */} @@ -456,73 +973,84 @@ export const UsageDashboard: React.FC = ({ onBack }) => { {t('usage.dailyUsage')} {stats.by_date.length > 0 ? (() => { - const maxCost = Math.max(...stats.by_date.map(d => d.total_cost), 0); - const halfMaxCost = maxCost / 2; + // 准备图表数据 + const chartData = stats.by_date.slice().reverse().map((day) => { + const date = new Date(day.date.replace(/-/g, '/')); + return { + date: date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), + fullDate: date.toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric' + }), + cost: day.total_cost, + tokens: day.total_tokens, + models: day.models_used.length + }; + }); + + // 自定义Tooltip + const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload[0]) { + const data = payload[0].payload; + return ( +
+

{data.fullDate}

+

+ {t('usage.cost')}: {formatCurrency(data.cost)} +

+

+ {formatTokens(data.tokens)} {t('usage.tokens')} +

+

+ {data.models} {t('usage.models')}{data.models !== 1 ? 's' : ''} +

+
+ ); + } + return null; + }; return ( -
- {/* Y-axis labels */} -
- {formatCurrency(maxCost)} - {formatCurrency(halfMaxCost)} - {formatCurrency(0)} -
- - {/* Chart container */} -
- {stats.by_date.slice().reverse().map((day) => { - const heightPercent = maxCost > 0 ? (day.total_cost / maxCost) * 100 : 0; - const date = new Date(day.date.replace(/-/g, '/')); - const formattedDate = date.toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric' - }); - - return ( -
- {/* Tooltip */} -
-
-

{formattedDate}

-

- {t('usage.cost')}: {formatCurrency(day.total_cost)} -

-

- {formatTokens(day.total_tokens)} {t('usage.tokens')} -

-

- {day.models_used.length} {t('usage.models')}{day.models_used.length !== 1 ? 's' : ''} -

-
-
-
-
-
- - {/* Bar */} -
- - {/* X-axis label – absolutely positioned below the bar so it doesn't affect bar height */} -
- {date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} -
-
- ); - })} -
- - {/* X-axis label */} -
- {t('usage.dailyUsageOverTime')} -
+
+ + + + + + + + + + + formatCurrency(value)} + className="text-muted-foreground" + /> + } /> + + +
- ) + ); })() : (
{t('usage.noUsageData')} diff --git a/src/lib/api.ts b/src/lib/api.ts index 9f77899..115c2be 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -211,6 +211,12 @@ export interface DailyUsage { date: string; total_cost: number; total_tokens: number; + // Detailed per-day breakdowns (backend added) + input_tokens: number; + output_tokens: number; + cache_creation_tokens: number; + cache_read_tokens: number; + request_count: number; models_used: string[]; } diff --git a/src/lib/usage-index.ts b/src/lib/usage-index.ts new file mode 100644 index 0000000..cfa6742 --- /dev/null +++ b/src/lib/usage-index.ts @@ -0,0 +1,38 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface ScanProgress { + processed: number; + total: number; + started_ts: number; + finished_ts?: number | null; +} + +export interface UsageSummary { + files: number; + tokens: number; + lines: number; + last_scan_ts?: number | null; +} + +export interface ImportResult { + inserted: number; + skipped: number; + errors: number; +} + +export async function usageScanIndex(projectRoot: string, exclude: string[] = []): Promise { + return await invoke("usage_scan_index", { projectRoot, exclude }); +} + +export async function usageScanProgress(jobId: string): Promise { + return await invoke("usage_scan_progress", { jobId }); +} + +export async function usageGetSummary(projectRoot: string): Promise { + return await invoke("usage_get_summary", { projectRoot }); +} + +export async function usageImportDiffs(projectRoot: string, path: string): Promise { + return await invoke("usage_import_diffs", { projectRoot, path }); +} + diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 6cc55fa..17ec81a 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -597,7 +597,18 @@ "tryAgain": "Try Again", "dailyUsageOverTime": "Daily Usage Over Time", "noUsageData": "No usage data available for the selected period", + "totalProjects": "Total Projects", + "avgProjectCost": "Average Project Cost", + "topProjectCost": "Highest Project Cost", + "projectCostDistribution": "Project Cost Distribution", + "projectTokenUsage": "Project Token Usage", + "projectCostRanking": "Project Cost Ranking", + "projectDetails": "Project Details", + "noProjectData": "No project data available", + "project": "Project", "cost": "Cost", + "requests": "Requests", + "times": "times", "lastUsed": "Last Used", "markdownEditorTitle": "Markdown Editor", "editSystemPrompt": "Edit your Claude Code system prompt", diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json index eb243e3..5debab6 100644 --- a/src/locales/zh/common.json +++ b/src/locales/zh/common.json @@ -578,7 +578,18 @@ "tryAgain": "重试", "dailyUsageOverTime": "随时间变化的日常用量", "noUsageData": "选定时期内无用量数据", + "totalProjects": "项目总数", + "avgProjectCost": "平均项目成本", + "topProjectCost": "最高项目成本", + "projectCostDistribution": "项目成本分布", + "projectTokenUsage": "项目 Token 使用量", + "projectCostRanking": "项目成本排行", + "projectDetails": "项目详情", + "noProjectData": "暂无项目数据", + "project": "项目", "cost": "成本", + "requests": "请求数", + "times": "次", "lastUsed": "上次使用", "markdownEditorTitle": "Markdown 编辑器", "editSystemPrompt": "编辑您的 Claude Code 系统提示",