完善UI
This commit is contained in:
@@ -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;
|
||||
|
@@ -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<String>,
|
||||
}
|
||||
|
||||
@@ -382,12 +388,15 @@ pub fn get_usage_stats(days: Option<u32>) -> Result<UsageStats, String> {
|
||||
|
||||
// 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<u32>) -> Result<UsageStats, String> {
|
||||
.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<U
|
||||
|
||||
// Parse dates
|
||||
let start = NaiveDate::parse_from_str(&start_date, "%Y-%m-%d").or_else(|_| {
|
||||
// Try parsing ISO datetime format
|
||||
// Try parsing ISO datetime format (convert to local date)
|
||||
DateTime::parse_from_rfc3339(&start_date)
|
||||
.map(|dt| dt.naive_local().date())
|
||||
.map(|dt| dt.with_timezone(&Local).date_naive())
|
||||
.map_err(|e| format!("Invalid start date: {}", e))
|
||||
})?;
|
||||
let end = NaiveDate::parse_from_str(&end_date, "%Y-%m-%d").or_else(|_| {
|
||||
// Try parsing ISO datetime format
|
||||
// Try parsing ISO datetime format (convert to local date)
|
||||
DateTime::parse_from_rfc3339(&end_date)
|
||||
.map(|dt| dt.naive_local().date())
|
||||
.map(|dt| dt.with_timezone(&Local).date_naive())
|
||||
.map_err(|e| format!("Invalid end date: {}", e))
|
||||
})?;
|
||||
|
||||
@@ -576,7 +600,7 @@ pub fn get_usage_by_date_range(start_date: String, end_date: String) -> Result<U
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
|
||||
let date = dt.naive_local().date();
|
||||
let date = dt.with_timezone(&Local).date_naive();
|
||||
date >= start && date <= end
|
||||
} else {
|
||||
false
|
||||
@@ -652,24 +676,38 @@ pub fn get_usage_by_date_range(start_date: String, end_date: String) -> Result<U
|
||||
.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 {
|
||||
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());
|
||||
}
|
||||
@@ -767,9 +805,16 @@ pub fn get_usage_details(
|
||||
all_entries.retain(|e| e.project_path == project);
|
||||
}
|
||||
|
||||
// Filter by date if specified
|
||||
// Filter by date if specified (compare against local date string YYYY-MM-DD)
|
||||
if let Some(date) = date {
|
||||
all_entries.retain(|e| e.timestamp.starts_with(&date));
|
||||
all_entries.retain(|e| {
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
|
||||
let local_date_str = dt.with_timezone(&Local).date_naive().to_string();
|
||||
local_date_str == date
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(all_entries)
|
||||
@@ -794,7 +839,7 @@ pub fn get_session_stats(
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
|
||||
let date = dt.date_naive();
|
||||
let date = dt.with_timezone(&Local).date_naive();
|
||||
let is_after_since = since_date.map_or(true, |s| date >= s);
|
||||
let is_before_until = until_date.map_or(true, |u| date <= u);
|
||||
is_after_since && is_before_until
|
||||
|
353
src-tauri/src/commands/usage_index.rs
Normal file
353
src-tauri/src/commands/usage_index.rs
Normal file
@@ -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<Mutex<HashMap<String, ScanProgress>>>, // 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<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UsageSummary {
|
||||
pub files: u64,
|
||||
pub tokens: u64,
|
||||
pub lines: u64,
|
||||
pub last_scan_ts: Option<i64>,
|
||||
}
|
||||
|
||||
#[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<Connection> {
|
||||
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<String> {
|
||||
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<String>) -> 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<Vec<String>>,
|
||||
state: State<'_, UsageIndexState>,
|
||||
) -> Result<String, String> {
|
||||
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<String> = 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<String,(i64,i64,String,i64)> = 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<String> = 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<i64>>(1).unwrap_or(None).unwrap_or(0), row.get::<_,Option<i64>>(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<ScanProgress, String> {
|
||||
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<UsageSummary, String> {
|
||||
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<i64> = 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<i64>>(0)?, r.get::<_,Option<i64>>(1)?, r.get::<_,Option<i64>>(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<i64>,
|
||||
#[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<ImportResult, String> {
|
||||
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<ExternalDiff> = Vec::new();
|
||||
match serde_json::from_str::<serde_json::Value>(&data) {
|
||||
Ok(serde_json::Value::Array(arr)) => {
|
||||
for v in arr { if let Ok(d) = serde_json::from_value::<ExternalDiff>(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::<ExternalDiff>(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<i64> = 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 })
|
||||
}
|
@@ -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,
|
||||
|
218
src/components/TokenUsageTrend.tsx
Normal file
218
src/components/TokenUsageTrend.tsx
Normal file
@@ -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<TokenUsageTrendProps> = ({ days }) => {
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(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 (
|
||||
<div className="absolute -translate-x-1/2 bottom-full mb-2 left-1/2 pointer-events-none">
|
||||
<div className="bg-background border border-border rounded-lg shadow-lg p-3 text-xs whitespace-nowrap">
|
||||
<div className="text-sm font-semibold mb-1">{dateText}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.cost }} />
|
||||
费用(USD):{fmtUSD(d.total_cost)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.cacheR }} />
|
||||
缓存读取Token: {fmtTokens(d.cache_read_tokens || 0)} tokens
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.cacheW }} />
|
||||
缓存创建Token: {fmtTokens(d.cache_creation_tokens || 0)} tokens
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.output }} />
|
||||
输出Token: {fmtTokens(d.output_tokens || 0)} tokens
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.input }} />
|
||||
输入Token: {fmtTokens(d.input_tokens || 0)} tokens
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.req }} />
|
||||
请求数:{d.request_count || 0} 次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">Token使用趋势</h3>
|
||||
<div className="relative w-full overflow-x-auto">
|
||||
<svg width={width} height={height} className="min-w-[900px]">
|
||||
{/* axes */}
|
||||
<line x1={padL} y1={padT} x2={padL} y2={padT + plotH} stroke={colors.grid} />
|
||||
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke={colors.grid} />
|
||||
{/* left ticks (tokens) 0, 25%, 50%, 75%, 100% */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((t) => (
|
||||
<g key={t}>
|
||||
<text x={8} y={padT + plotH * (1 - t)} className="text-[10px]" fill={colors.text}>
|
||||
{fmtTokens(Math.round(maxTokens * t))}
|
||||
</text>
|
||||
<line
|
||||
x1={padL}
|
||||
y1={padT + plotH * (1 - t)}
|
||||
x2={padL + plotW}
|
||||
y2={padT + plotH * (1 - t)}
|
||||
stroke={colors.grid}
|
||||
strokeDasharray="2,4"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
{/* right ticks (cost/requests) */}
|
||||
{[0, 0.5, 1].map((t) => (
|
||||
<g key={`r-${t}`}>
|
||||
<text x={padL + plotW + 4} y={padT + plotH * (1 - t)} className="text-[10px]" fill={colors.text}>
|
||||
{t === 1 ? fmtUSD(maxCost) : t === 0.5 ? fmtUSD(maxCost / 2) : "$0"}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* token lines */}
|
||||
<path d={pathFrom(series.input, yToken)} fill="none" stroke={colors.input} strokeWidth={2} />
|
||||
<path d={pathFrom(series.output, yToken)} fill="none" stroke={colors.output} strokeWidth={2} />
|
||||
<path d={pathFrom(series.cacheW, yToken)} fill="none" stroke={colors.cacheW} strokeWidth={2} />
|
||||
<path d={pathFrom(series.cacheR, yToken)} fill="none" stroke={colors.cacheR} strokeWidth={2} />
|
||||
|
||||
{/* cost line (right axis) */}
|
||||
<path d={pathFrom(series.cost, yCost)} fill="none" stroke={colors.cost} strokeWidth={2} />
|
||||
|
||||
{/* requests as small circles on right scale */}
|
||||
{series.reqs.map((v, i) => (
|
||||
<circle key={`req-${i}`} cx={x(i)} cy={yReq(v)} r={2.5} fill={colors.req} />
|
||||
))}
|
||||
|
||||
{/* x labels and hover hit-areas */}
|
||||
{labels.map((lab, i) => (
|
||||
<g key={i}
|
||||
onMouseEnter={() => setHoverIndex(i)}
|
||||
onMouseLeave={() => setHoverIndex(null)}>
|
||||
<text
|
||||
x={x(i)}
|
||||
y={padT + plotH + 16}
|
||||
textAnchor="middle"
|
||||
className="text-[10px]"
|
||||
fill={colors.text}
|
||||
>
|
||||
{lab}
|
||||
</text>
|
||||
{/* vertical hover guide */}
|
||||
{hoverIndex === i && (
|
||||
<line x1={x(i)} y1={padT} x2={x(i)} y2={padT + plotH} stroke={colors.grid} />
|
||||
)}
|
||||
{/* invisible hit area */}
|
||||
<rect x={x(i) - plotW / Math.max(1, n - 1) / 2}
|
||||
y={padT}
|
||||
width={plotW / Math.max(1, n - 1)}
|
||||
height={plotH}
|
||||
fill="transparent" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
{/* Tooltip container */}
|
||||
{hoverIndex != null && (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: `${((padL + (plotW * hoverIndex) / Math.max(1, n - 1)) / width) * 100}%`, bottom: padB + 8 }}
|
||||
>
|
||||
{renderTooltip()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* legend */}
|
||||
<div className="flex flex-wrap gap-4 mt-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.input }} />输入Token</div>
|
||||
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.output }} />输出Token</div>
|
||||
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.cacheW }} />缓存创建Token</div>
|
||||
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.cacheR }} />缓存读取Token</div>
|
||||
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.cost }} />费用(USD)</div>
|
||||
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.req }} />请求数</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
@@ -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<UsageDashboardProps> = ({ onBack }) => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 使用趋势图表 - 整合了Token使用趋势 */}
|
||||
{stats.by_date.length > 1 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('usage.dailyUsageOverTime')}</h3>
|
||||
<div className="w-full h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={stats.by_date.slice().reverse().map((day) => ({
|
||||
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 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border/20" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(value) => `${value}K`}
|
||||
label={{ value: 'Tokens (K)', angle: -90, position: 'insideLeft', style: { fontSize: 10 } }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(value) => `$${value.toFixed(2)}`}
|
||||
label={{ value: 'Cost (USD)', angle: 90, position: 'insideRight', style: { fontSize: 10 } }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04)',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}}
|
||||
labelStyle={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
marginBottom: '8px',
|
||||
color: 'hsl(var(--popover-foreground))'
|
||||
}}
|
||||
itemStyle={{
|
||||
fontSize: 11,
|
||||
padding: '2px 0'
|
||||
}}
|
||||
formatter={(value: any, name: string) => {
|
||||
// 定义线条颜色映射
|
||||
const colorMap: Record<string, string> = {
|
||||
'inputTokens': '#3b82f6',
|
||||
'outputTokens': '#ec4899',
|
||||
'cacheWriteTokens': '#60a5fa',
|
||||
'cacheReadTokens': '#a78bfa',
|
||||
'cost': '#22c55e',
|
||||
'requests': '#f59e0b'
|
||||
};
|
||||
|
||||
// 获取翻译名称
|
||||
const nameMap: Record<string, string> = {
|
||||
'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 [
|
||||
<span style={{ color: colorMap[name] || 'inherit' }}>
|
||||
{formattedValue}
|
||||
</span>,
|
||||
nameMap[name] || name
|
||||
];
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
iconType="line"
|
||||
formatter={(value) => {
|
||||
const nameMap: Record<string, string> = {
|
||||
'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 线条 - 左轴 */}
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="inputTokens"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2 }}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="outputTokens"
|
||||
stroke="#ec4899"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2 }}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="cacheWriteTokens"
|
||||
stroke="#60a5fa"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="5 5"
|
||||
dot={{ r: 2 }}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="cacheReadTokens"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="5 5"
|
||||
dot={{ r: 2 }}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
|
||||
{/* 费用线条 - 右轴 */}
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="cost"
|
||||
stroke="#22c55e"
|
||||
strokeWidth={2.5}
|
||||
dot={{ r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="p-6">
|
||||
@@ -341,81 +534,405 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
||||
|
||||
{/* Models Tab */}
|
||||
<TabsContent value="models">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('usage.usageByModel')}</h3>
|
||||
<div className="space-y-4">
|
||||
{stats.by_model.map((model) => (
|
||||
<div key={model.model} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", getModelColor(model.model))}
|
||||
>
|
||||
{getModelDisplayName(model.model)}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{model.session_count} {t('usage.sessions')}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* 饼图 */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('usage.usageByModel')}</h3>
|
||||
<div className="w-full h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={stats.by_model.map((model) => ({
|
||||
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) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={['#d97757', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6'][index % 5]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04)',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}}
|
||||
labelStyle={{
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
fontWeight: 600
|
||||
}}
|
||||
itemStyle={{
|
||||
color: 'hsl(var(--popover-foreground))'
|
||||
}}
|
||||
formatter={(value: number, name: string, props: any) => {
|
||||
if (name === 'value') {
|
||||
return [
|
||||
formatCurrency(value),
|
||||
`${props.payload.sessions} sessions, ${formatTokens(props.payload.tokens)} tokens`
|
||||
];
|
||||
}
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 详细列表 */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">详细统计</h3>
|
||||
<div className="space-y-4">
|
||||
{stats.by_model.map((model) => (
|
||||
<div key={model.model} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", getModelColor(model.model))}
|
||||
>
|
||||
{getModelDisplayName(model.model)}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{model.session_count} {t('usage.sessions')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">
|
||||
{formatCurrency(model.total_cost)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">
|
||||
{formatCurrency(model.total_cost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('usage.input')}: </span>
|
||||
<span className="font-medium">{formatTokens(model.input_tokens)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('usage.output')}: </span>
|
||||
<span className="font-medium">{formatTokens(model.output_tokens)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Cache W: </span>
|
||||
<span className="font-medium">{formatTokens(model.cache_creation_tokens)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Cache R: </span>
|
||||
<span className="font-medium">{formatTokens(model.cache_read_tokens)}</span>
|
||||
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('usage.input')}: </span>
|
||||
<span className="font-medium">{formatTokens(model.input_tokens)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('usage.output')}: </span>
|
||||
<span className="font-medium">{formatTokens(model.output_tokens)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Cache W: </span>
|
||||
<span className="font-medium">{formatTokens(model.cache_creation_tokens)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Cache R: </span>
|
||||
<span className="font-medium">{formatTokens(model.cache_read_tokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Projects Tab */}
|
||||
<TabsContent value="projects">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('usage.usageByProject')}</h3>
|
||||
<div className="space-y-3">
|
||||
{stats.by_project.map((project) => (
|
||||
<div key={project.project_path} className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
||||
<div className="flex flex-col truncate">
|
||||
<span className="text-sm font-medium truncate" title={project.project_path}>
|
||||
{project.project_path}
|
||||
</span>
|
||||
<div className="flex items-center space-x-3 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{project.session_count} {t('usage.sessions')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTokens(project.total_tokens)} {t('usage.tokens')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">{formatCurrency(project.total_cost)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatCurrency(project.total_cost / project.session_count)}/{t('usage.session')}
|
||||
<div className="space-y-4">
|
||||
{/* 顶部统计卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t('usage.totalProjects')}</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{stats.by_project.length}
|
||||
</p>
|
||||
</div>
|
||||
<Briefcase className="h-8 w-8 text-muted-foreground/20" />
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t('usage.avgProjectCost')}</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{formatCurrency(
|
||||
stats.by_project.length > 0
|
||||
? stats.by_project.reduce((sum, p) => sum + p.total_cost, 0) / stats.by_project.length
|
||||
: 0
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-muted-foreground/20" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t('usage.topProjectCost')}</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{stats.by_project.length > 0
|
||||
? formatCurrency(Math.max(...stats.by_project.map(p => p.total_cost)))
|
||||
: '$0.00'}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="h-8 w-8 text-muted-foreground/20" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 图表区域 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* 成本分布饼图 */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('usage.projectCostDistribution')}</h3>
|
||||
{stats.by_project.length > 0 ? (
|
||||
<div className="w-full h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={stats.by_project.slice(0, 8).map((project) => ({
|
||||
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) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={['#3b82f6', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#f43f5e', '#84cc16'][index % 8]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04)',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}}
|
||||
labelStyle={{
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
fontWeight: 600
|
||||
}}
|
||||
itemStyle={{
|
||||
color: 'hsl(var(--popover-foreground))'
|
||||
}}
|
||||
formatter={(value: number, name: string, props: any) => {
|
||||
if (name === 'value') {
|
||||
return [
|
||||
formatCurrency(value),
|
||||
`${props.payload.sessions} ${t('usage.sessions')}, ${formatTokens(props.payload.tokens)} tokens`
|
||||
];
|
||||
}
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
wrapperStyle={{ fontSize: 10 }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-80 text-muted-foreground">
|
||||
{t('usage.noProjectData')}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Token使用柱状图 */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('usage.projectTokenUsage')}</h3>
|
||||
{stats.by_project.length > 0 ? (
|
||||
<div className="w-full h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={stats.by_project.slice(0, 6).map((project) => ({
|
||||
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 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border/20" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 10 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(value) => `${value}K`}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04)',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}}
|
||||
formatter={(value: number) => `${formatTokens(value * 1000)} tokens`}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
formatter={(value) => {
|
||||
const nameMap: Record<string, string> = {
|
||||
'input': t('usage.inputTokens'),
|
||||
'output': t('usage.outputTokens'),
|
||||
'cacheWrite': t('usage.cacheWrite'),
|
||||
'cacheRead': t('usage.cacheRead')
|
||||
};
|
||||
return nameMap[value] || value;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="input" stackId="a" fill="#3b82f6" />
|
||||
<Bar dataKey="output" stackId="a" fill="#ec4899" />
|
||||
<Bar dataKey="cacheWrite" stackId="a" fill="#60a5fa" />
|
||||
<Bar dataKey="cacheRead" stackId="a" fill="#a78bfa" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-80 text-muted-foreground">
|
||||
{t('usage.noProjectData')}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 成本排行条形图 */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('usage.projectCostRanking')}</h3>
|
||||
{stats.by_project.length > 0 && (
|
||||
<div className="w-full h-96 mb-6">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={stats.by_project.slice(0, 10).map((project) => ({
|
||||
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 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border/20" />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(value) => formatCurrency(value)}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 10 }}
|
||||
width={90}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
fontSize: 11
|
||||
}}
|
||||
labelStyle={{
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
fontWeight: 600
|
||||
}}
|
||||
itemStyle={{
|
||||
color: 'hsl(var(--popover-foreground))'
|
||||
}}
|
||||
formatter={(value: number, name: string, props: any) => {
|
||||
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}`}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="cost"
|
||||
fill="#3b82f6"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 详细列表 */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('usage.projectDetails')}</h3>
|
||||
<div className="space-y-3">
|
||||
{stats.by_project.map((project) => (
|
||||
<div key={project.project_path} className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
||||
<div className="flex flex-col truncate">
|
||||
<span className="text-sm font-medium truncate" title={project.project_path}>
|
||||
{project.project_path}
|
||||
</span>
|
||||
<div className="flex items-center space-x-3 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{project.session_count} {t('usage.sessions')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTokens(project.total_tokens)} {t('usage.tokens')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">{formatCurrency(project.total_cost)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatCurrency(project.total_cost / project.session_count)}/{t('usage.session')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Sessions Tab */}
|
||||
@@ -456,73 +973,84 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
||||
<span>{t('usage.dailyUsage')}</span>
|
||||
</h3>
|
||||
{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 (
|
||||
<div className="bg-background border border-border rounded-lg shadow-lg p-3">
|
||||
<p className="text-sm font-semibold">{data.fullDate}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('usage.cost')}: {formatCurrency(data.cost)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatTokens(data.tokens)} {t('usage.tokens')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.models} {t('usage.models')}{data.models !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative pl-8 pr-4">
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-8 flex flex-col justify-between text-xs text-muted-foreground">
|
||||
<span>{formatCurrency(maxCost)}</span>
|
||||
<span>{formatCurrency(halfMaxCost)}</span>
|
||||
<span>{formatCurrency(0)}</span>
|
||||
</div>
|
||||
|
||||
{/* Chart container */}
|
||||
<div className="flex items-end space-x-2 h-64 border-l border-b border-border pl-4">
|
||||
{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 (
|
||||
<div key={day.date} className="flex-1 h-full flex flex-col items-center justify-end group relative">
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10">
|
||||
<div className="bg-background border border-border rounded-lg shadow-lg p-3 whitespace-nowrap">
|
||||
<p className="text-sm font-semibold">{formattedDate}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('usage.cost')}: {formatCurrency(day.total_cost)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatTokens(day.total_tokens)} {t('usage.tokens')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{day.models_used.length} {t('usage.models')}{day.models_used.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 -mt-1">
|
||||
<div className="border-4 border-transparent border-t-border"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div
|
||||
className="w-full bg-[#d97757] hover:opacity-80 transition-opacity rounded-t cursor-pointer"
|
||||
style={{ height: `${heightPercent}%` }}
|
||||
/>
|
||||
|
||||
{/* X-axis label – absolutely positioned below the bar so it doesn't affect bar height */}
|
||||
<div
|
||||
className="absolute left-1/2 top-full mt-1 -translate-x-1/2 text-xs text-muted-foreground -rotate-45 origin-top-left whitespace-nowrap pointer-events-none"
|
||||
>
|
||||
{date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* X-axis label */}
|
||||
<div className="mt-8 text-center text-xs text-muted-foreground">
|
||||
{t('usage.dailyUsageOverTime')}
|
||||
</div>
|
||||
<div className="w-full h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 30, left: 0, bottom: 40 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorCost" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#d97757" stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor="#d97757" stopOpacity={0.1}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 11 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(value) => formatCurrency(value)}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cost"
|
||||
stroke="#d97757"
|
||||
strokeWidth={2}
|
||||
fill="url(#colorCost)"
|
||||
animationDuration={1000}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})() : (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
{t('usage.noUsageData')}
|
||||
|
@@ -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[];
|
||||
}
|
||||
|
||||
|
38
src/lib/usage-index.ts
Normal file
38
src/lib/usage-index.ts
Normal file
@@ -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<string> {
|
||||
return await invoke<string>("usage_scan_index", { projectRoot, exclude });
|
||||
}
|
||||
|
||||
export async function usageScanProgress(jobId: string): Promise<ScanProgress> {
|
||||
return await invoke<ScanProgress>("usage_scan_progress", { jobId });
|
||||
}
|
||||
|
||||
export async function usageGetSummary(projectRoot: string): Promise<UsageSummary> {
|
||||
return await invoke<UsageSummary>("usage_get_summary", { projectRoot });
|
||||
}
|
||||
|
||||
export async function usageImportDiffs(projectRoot: string, path: string): Promise<ImportResult> {
|
||||
return await invoke<ImportResult>("usage_import_diffs", { projectRoot, path });
|
||||
}
|
||||
|
@@ -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",
|
||||
|
@@ -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 系统提示",
|
||||
|
Reference in New Issue
Block a user