This commit is contained in:
2025-08-10 01:46:10 +08:00
parent dbda05e688
commit dcd6b42a66
10 changed files with 1382 additions and 159 deletions

View File

@@ -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;

View File

@@ -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

View 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 })
}

View File

@@ -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,

View 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>
);
};

View File

@@ -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')}

View File

@@ -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
View 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 });
}

View File

@@ -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",

View File

@@ -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 系统提示",