完善UI
This commit is contained in:
@@ -2,6 +2,7 @@ pub mod agents;
|
|||||||
pub mod claude;
|
pub mod claude;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
pub mod usage_index;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod slash_commands;
|
pub mod slash_commands;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
|
@@ -50,6 +50,12 @@ pub struct DailyUsage {
|
|||||||
date: String,
|
date: String,
|
||||||
total_cost: f64,
|
total_cost: f64,
|
||||||
total_tokens: u64,
|
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>,
|
models_used: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,12 +388,15 @@ pub fn get_usage_stats(days: Option<u32>) -> Result<UsageStats, String> {
|
|||||||
|
|
||||||
// Filter by days if specified
|
// Filter by days if specified
|
||||||
let filtered_entries = if let Some(days) = days {
|
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
|
all_entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
|
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 {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -450,24 +459,39 @@ pub fn get_usage_stats(days: Option<u32>) -> Result<UsageStats, String> {
|
|||||||
.or_insert_with(HashSet::new)
|
.or_insert_with(HashSet::new)
|
||||||
.insert(entry.session_id.clone());
|
.insert(entry.session_id.clone());
|
||||||
|
|
||||||
// Update daily stats
|
// Update daily stats (use local timezone date)
|
||||||
let date = entry
|
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
|
.timestamp
|
||||||
.split('T')
|
.split('T')
|
||||||
.next()
|
.next()
|
||||||
.unwrap_or(&entry.timestamp)
|
.unwrap_or(&entry.timestamp)
|
||||||
.to_string();
|
.to_string()
|
||||||
|
};
|
||||||
let daily_stat = daily_stats.entry(date.clone()).or_insert(DailyUsage {
|
let daily_stat = daily_stats.entry(date.clone()).or_insert(DailyUsage {
|
||||||
date,
|
date,
|
||||||
total_cost: 0.0,
|
total_cost: 0.0,
|
||||||
total_tokens: 0,
|
total_tokens: 0,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
request_count: 0,
|
||||||
models_used: vec![],
|
models_used: vec![],
|
||||||
});
|
});
|
||||||
daily_stat.total_cost += entry.cost;
|
daily_stat.total_cost += entry.cost;
|
||||||
daily_stat.total_tokens += entry.input_tokens
|
daily_stat.input_tokens += entry.input_tokens;
|
||||||
+ entry.output_tokens
|
daily_stat.output_tokens += entry.output_tokens;
|
||||||
+ entry.cache_creation_tokens
|
daily_stat.cache_creation_tokens += entry.cache_creation_tokens;
|
||||||
+ entry.cache_read_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) {
|
if !daily_stat.models_used.contains(&entry.model) {
|
||||||
daily_stat.models_used.push(entry.model.clone());
|
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
|
// Parse dates
|
||||||
let start = NaiveDate::parse_from_str(&start_date, "%Y-%m-%d").or_else(|_| {
|
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)
|
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))
|
.map_err(|e| format!("Invalid start date: {}", e))
|
||||||
})?;
|
})?;
|
||||||
let end = NaiveDate::parse_from_str(&end_date, "%Y-%m-%d").or_else(|_| {
|
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)
|
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))
|
.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()
|
.into_iter()
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
|
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
|
date >= start && date <= end
|
||||||
} else {
|
} else {
|
||||||
false
|
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)
|
.or_insert_with(HashSet::new)
|
||||||
.insert(entry.session_id.clone());
|
.insert(entry.session_id.clone());
|
||||||
|
|
||||||
// Update daily stats
|
// Update daily stats (use local timezone date)
|
||||||
let date = entry
|
let date = if let Ok(dt) = DateTime::parse_from_rfc3339(&entry.timestamp) {
|
||||||
|
dt.with_timezone(&Local).date_naive().to_string()
|
||||||
|
} else {
|
||||||
|
entry
|
||||||
.timestamp
|
.timestamp
|
||||||
.split('T')
|
.split('T')
|
||||||
.next()
|
.next()
|
||||||
.unwrap_or(&entry.timestamp)
|
.unwrap_or(&entry.timestamp)
|
||||||
.to_string();
|
.to_string()
|
||||||
|
};
|
||||||
let daily_stat = daily_stats.entry(date.clone()).or_insert(DailyUsage {
|
let daily_stat = daily_stats.entry(date.clone()).or_insert(DailyUsage {
|
||||||
date,
|
date,
|
||||||
total_cost: 0.0,
|
total_cost: 0.0,
|
||||||
total_tokens: 0,
|
total_tokens: 0,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
request_count: 0,
|
||||||
models_used: vec![],
|
models_used: vec![],
|
||||||
});
|
});
|
||||||
daily_stat.total_cost += entry.cost;
|
daily_stat.total_cost += entry.cost;
|
||||||
daily_stat.total_tokens += entry.input_tokens
|
daily_stat.input_tokens += entry.input_tokens;
|
||||||
+ entry.output_tokens
|
daily_stat.output_tokens += entry.output_tokens;
|
||||||
+ entry.cache_creation_tokens
|
daily_stat.cache_creation_tokens += entry.cache_creation_tokens;
|
||||||
+ entry.cache_read_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) {
|
if !daily_stat.models_used.contains(&entry.model) {
|
||||||
daily_stat.models_used.push(entry.model.clone());
|
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);
|
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 {
|
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)
|
Ok(all_entries)
|
||||||
@@ -794,7 +839,7 @@ pub fn get_session_stats(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
|
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_after_since = since_date.map_or(true, |s| date >= s);
|
||||||
let is_before_until = until_date.map_or(true, |u| date <= u);
|
let is_before_until = until_date.map_or(true, |u| date <= u);
|
||||||
is_after_since && is_before_until
|
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::{
|
use commands::usage::{
|
||||||
get_session_stats, get_usage_by_date_range, get_usage_details, get_usage_stats,
|
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::{
|
use commands::storage::{
|
||||||
storage_list_tables, storage_read_table, storage_update_row, storage_delete_row,
|
storage_list_tables, storage_read_table, storage_update_row, storage_delete_row,
|
||||||
storage_insert_row, storage_execute_sql, storage_reset_database,
|
storage_insert_row, storage_execute_sql, storage_reset_database,
|
||||||
@@ -160,6 +163,9 @@ fn main() {
|
|||||||
// Initialize Claude process state
|
// Initialize Claude process state
|
||||||
app.manage(ClaudeProcessState::default());
|
app.manage(ClaudeProcessState::default());
|
||||||
|
|
||||||
|
// Initialize Usage Index state
|
||||||
|
app.manage(UsageIndexState::default());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -241,6 +247,12 @@ fn main() {
|
|||||||
get_usage_details,
|
get_usage_details,
|
||||||
get_session_stats,
|
get_session_stats,
|
||||||
|
|
||||||
|
// File Usage Index (SQLite)
|
||||||
|
usage_scan_index,
|
||||||
|
usage_scan_progress,
|
||||||
|
usage_get_summary,
|
||||||
|
usage_import_diffs,
|
||||||
|
|
||||||
// MCP (Model Context Protocol)
|
// MCP (Model Context Protocol)
|
||||||
mcp_add,
|
mcp_add,
|
||||||
mcp_list,
|
mcp_list,
|
||||||
|
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";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslation } from "@/hooks/useTranslation";
|
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 {
|
interface UsageDashboardProps {
|
||||||
/**
|
/**
|
||||||
@@ -293,6 +310,182 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 */}
|
{/* Quick Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
@@ -341,8 +534,74 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
|
|
||||||
{/* Models Tab */}
|
{/* Models Tab */}
|
||||||
<TabsContent value="models">
|
<TabsContent value="models">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* 饼图 */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-sm font-semibold mb-4">{t('usage.usageByModel')}</h3>
|
<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">
|
<div className="space-y-4">
|
||||||
{stats.by_model.map((model) => (
|
{stats.by_model.map((model) => (
|
||||||
<div key={model.model} className="space-y-2">
|
<div key={model.model} className="space-y-2">
|
||||||
@@ -384,12 +643,269 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Projects Tab */}
|
{/* Projects Tab */}
|
||||||
<TabsContent value="projects">
|
<TabsContent value="projects">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 图表区域 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* 成本分布饼图 */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-sm font-semibold mb-4">{t('usage.usageByProject')}</h3>
|
<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">
|
<div className="space-y-3">
|
||||||
{stats.by_project.map((project) => (
|
{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 key={project.project_path} className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
||||||
@@ -416,6 +932,7 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Sessions Tab */}
|
{/* Sessions Tab */}
|
||||||
@@ -456,73 +973,84 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
<span>{t('usage.dailyUsage')}</span>
|
<span>{t('usage.dailyUsage')}</span>
|
||||||
</h3>
|
</h3>
|
||||||
{stats.by_date.length > 0 ? (() => {
|
{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) => {
|
||||||
|
|
||||||
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 date = new Date(day.date.replace(/-/g, '/'));
|
||||||
const formattedDate = date.toLocaleDateString('en-US', {
|
return {
|
||||||
|
date: date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||||
|
fullDate: date.toLocaleDateString(undefined, {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
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 (
|
return (
|
||||||
<div key={day.date} className="flex-1 h-full flex flex-col items-center justify-end group relative">
|
<div className="bg-background border border-border rounded-lg shadow-lg p-3">
|
||||||
{/* Tooltip */}
|
<p className="text-sm font-semibold">{data.fullDate}</p>
|
||||||
<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">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{t('usage.cost')}: {formatCurrency(day.total_cost)}
|
{t('usage.cost')}: {formatCurrency(data.cost)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formatTokens(day.total_tokens)} {t('usage.tokens')}
|
{formatTokens(data.tokens)} {t('usage.tokens')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{day.models_used.length} {t('usage.models')}{day.models_used.length !== 1 ? 's' : ''}
|
{data.models} {t('usage.models')}{data.models !== 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
{/* X-axis label */}
|
return (
|
||||||
<div className="mt-8 text-center text-xs text-muted-foreground">
|
<div className="w-full h-80">
|
||||||
{t('usage.dailyUsageOverTime')}
|
<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>
|
||||||
</div>
|
);
|
||||||
)
|
|
||||||
})() : (
|
})() : (
|
||||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||||
{t('usage.noUsageData')}
|
{t('usage.noUsageData')}
|
||||||
|
@@ -211,6 +211,12 @@ export interface DailyUsage {
|
|||||||
date: string;
|
date: string;
|
||||||
total_cost: number;
|
total_cost: number;
|
||||||
total_tokens: 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[];
|
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",
|
"tryAgain": "Try Again",
|
||||||
"dailyUsageOverTime": "Daily Usage Over Time",
|
"dailyUsageOverTime": "Daily Usage Over Time",
|
||||||
"noUsageData": "No usage data available for the selected period",
|
"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",
|
"cost": "Cost",
|
||||||
|
"requests": "Requests",
|
||||||
|
"times": "times",
|
||||||
"lastUsed": "Last Used",
|
"lastUsed": "Last Used",
|
||||||
"markdownEditorTitle": "Markdown Editor",
|
"markdownEditorTitle": "Markdown Editor",
|
||||||
"editSystemPrompt": "Edit your Claude Code system prompt",
|
"editSystemPrompt": "Edit your Claude Code system prompt",
|
||||||
|
@@ -578,7 +578,18 @@
|
|||||||
"tryAgain": "重试",
|
"tryAgain": "重试",
|
||||||
"dailyUsageOverTime": "随时间变化的日常用量",
|
"dailyUsageOverTime": "随时间变化的日常用量",
|
||||||
"noUsageData": "选定时期内无用量数据",
|
"noUsageData": "选定时期内无用量数据",
|
||||||
|
"totalProjects": "项目总数",
|
||||||
|
"avgProjectCost": "平均项目成本",
|
||||||
|
"topProjectCost": "最高项目成本",
|
||||||
|
"projectCostDistribution": "项目成本分布",
|
||||||
|
"projectTokenUsage": "项目 Token 使用量",
|
||||||
|
"projectCostRanking": "项目成本排行",
|
||||||
|
"projectDetails": "项目详情",
|
||||||
|
"noProjectData": "暂无项目数据",
|
||||||
|
"project": "项目",
|
||||||
"cost": "成本",
|
"cost": "成本",
|
||||||
|
"requests": "请求数",
|
||||||
|
"times": "次",
|
||||||
"lastUsed": "上次使用",
|
"lastUsed": "上次使用",
|
||||||
"markdownEditorTitle": "Markdown 编辑器",
|
"markdownEditorTitle": "Markdown 编辑器",
|
||||||
"editSystemPrompt": "编辑您的 Claude Code 系统提示",
|
"editSystemPrompt": "编辑您的 Claude Code 系统提示",
|
||||||
|
Reference in New Issue
Block a user