feat: 添加用量统计面板 + 优化托盘 app 启动方式

- 新增用量统计 tab,按项目展示 token 消耗、会话数、模型分布
- 新增 usage.rs 模块解析 Claude 日志计算用量
- 托盘 app 改用 release 编译产物直接启动,移除 claude-hub-bin
- 项目窗口管理:修复新建 session 时 _init 窗口清理逻辑
- 新增 build-app.sh 构建脚本

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
wuguanganandClaude Opus 4.6 committed 2026-03-25 21:06:21 +08:00
1 parent 0bca945994
commit ffa18e2575
10 files changed
+660 -15

No files matched your search

-1
View File
@@ -8,4 +8,3 @@ sounds/
projects.yaml
app/src-tauri/target/
app/node_modules/
app/claude-hub-bin
+2 -2
View File
@@ -76,9 +76,9 @@ done
每次新会话启动时,**立即自动执行**以下步骤(不询问用户、不等待确认):
1. **启动通知面板** — 检查 `pgrep -f "target/.*claude-hub"` 是否已运行,未运行则后台启动:
1. **启动通知面板** — 检查 `pgrep -f "claude-hub"` 是否已运行,未运行则后台启动:
```bash
cd ~/Documents/code/claude-hub/app && ~/.cargo/bin/cargo tauri dev &>/dev/null &
~/Documents/code/claude-hub/app/src-tauri/target/release/claude-hub &>/dev/null &
```
2. **显示项目列表** — `bash scripts/welcome.sh`
3. **检查 tmux hub** — `tmux has-session -t hub 2>/dev/null`,报告状态
+7
View File
@@ -3,6 +3,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod projects;
mod usage;
mod watcher;
use serde::{Deserialize, Serialize};
@@ -36,6 +37,11 @@ fn get_projects() -> Vec<projects::Project> {
projects::load_projects()
}
#[tauri::command]
fn get_usage_stats(range: String) -> usage::UsageStats {
usage::compute_usage(&range)
}
#[tauri::command]
fn focus_project(project: String) {
#[cfg(target_os = "macos")]
@@ -69,6 +75,7 @@ fn main() {
.invoke_handler(tauri::generate_handler![
get_events,
get_projects,
get_usage_stats,
focus_project,
open_project
])
+10
View File
@@ -123,6 +123,8 @@ pub fn open_in_tmux(name: &str, path: &str, mode: &str) {
.ok();
}
let need_cleanup = !hub_exists; // 新建 session 时需要清理 _init
match mode {
"claude" => {
let cmd = format!("cd '{}' && claude", path);
@@ -162,6 +164,14 @@ pub fn open_in_tmux(name: &str, path: &str, mode: &str) {
_ => return,
}
// 清理 _init 窗口(新建 session 时会产生)
if need_cleanup {
Command::new("tmux")
.args(["kill-window", "-t", "hub:_init"])
.status()
.ok();
}
// 确保 iTerm2 连接到 hub
ensure_client_attached();
}
+309
View File
@@ -0,0 +1,309 @@
// 扫描 ~/.claude/projects/ 下的会话 JSONL,按项目统计 token 使用量
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize)]
pub struct ModelUsage {
pub model: String,
pub display_name: String,
pub message_count: u32,
pub output_tokens: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectUsage {
pub name: String,
pub path: String,
pub session_count: u32,
pub message_count: u32,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_tokens: u64,
pub cache_create_tokens: u64,
pub last_active: String,
pub models: Vec<ModelUsage>,
}
#[derive(Debug, Clone, Serialize)]
pub struct UsageStats {
pub projects: Vec<ProjectUsage>,
pub total_sessions: u32,
pub total_messages: u32,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
}
#[derive(Deserialize)]
struct JLine {
#[serde(rename = "type")]
line_type: Option<String>,
message: Option<JMessage>,
timestamp: Option<String>,
}
#[derive(Deserialize)]
struct JMessage {
model: Option<String>,
usage: Option<JUsage>,
}
#[derive(Deserialize)]
struct JUsage {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
cache_read_input_tokens: Option<u64>,
cache_creation_input_tokens: Option<u64>,
}
/// 从 projects.yaml 构建路径 → 项目名的映射
fn build_path_map() -> HashMap<String, String> {
let home = std::env::var("HOME").unwrap_or_default();
let yaml_path = format!("{}/Documents/code/claude-hub/projects.yaml", home);
let content = match fs::read_to_string(&yaml_path) {
Ok(c) => c,
Err(_) => return HashMap::new(),
};
let mut map = HashMap::new();
let mut current_name = String::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("- name:") {
current_name = trimmed.trim_start_matches("- name:").trim().to_string();
} else if trimmed.starts_with("path:") && !current_name.is_empty() {
let mut path = trimmed.trim_start_matches("path:").trim().to_string();
if path.starts_with("~/") {
path = format!("{}/{}", home, &path[2..]);
}
// 编码路径:/Users/dev/Documents/code/xxx → -Users-dev-Documents-code-xxx
let encoded = path.replace('/', "-");
// 去掉开头的 -
let encoded = encoded.trim_start_matches('-').to_string();
map.insert(encoded, current_name.clone());
}
}
map
}
/// 将目录名解码为可读路径
fn decode_dir_name(dir_name: &str) -> String {
format!("/{}", dir_name.replace('-', "/"))
}
/// 计算截止日期
fn cutoff_date(range: &str) -> Option<String> {
let now = chrono::Utc::now();
let days = match range {
"7d" => 7,
"30d" => 30,
_ => return None,
};
let cutoff = now - chrono::Duration::days(days);
Some(cutoff.format("%Y-%m-%dT%H:%M:%S").to_string())
}
/// 将模型 ID 转为人类可读名称
fn format_model_name(model_id: &str) -> String {
match model_id {
s if s.starts_with("claude-opus-4-6") => "Opus 4.6".to_string(),
s if s.starts_with("claude-opus-4-5") => "Opus 4.5".to_string(),
s if s.starts_with("claude-sonnet-4-6") => "Sonnet 4.6".to_string(),
s if s.starts_with("claude-sonnet-4-5") => "Sonnet 4.5".to_string(),
s if s.starts_with("claude-haiku-4-5") => "Haiku 4.5".to_string(),
s if s.starts_with("claude-3-5-sonnet") => "Sonnet 3.5".to_string(),
s if s.starts_with("claude-3-5-haiku") => "Haiku 3.5".to_string(),
s if s.starts_with("claude-3-opus") => "Opus 3".to_string(),
_ => model_id.to_string(),
}
}
pub fn compute_usage(range: &str) -> UsageStats {
let home = std::env::var("HOME").unwrap_or_default();
let projects_dir = PathBuf::from(&home).join(".claude").join("projects");
let path_map = build_path_map();
let cutoff = cutoff_date(range);
let mut project_usages: HashMap<String, ProjectUsage> = HashMap::new();
// 按项目 → 模型 → (message_count, output_tokens) 聚合
let mut project_models: HashMap<String, HashMap<String, (u32, u64)>> = HashMap::new();
let entries = match fs::read_dir(&projects_dir) {
Ok(e) => e,
Err(_) => {
return UsageStats {
projects: vec![],
total_sessions: 0,
total_messages: 0,
total_input_tokens: 0,
total_output_tokens: 0,
}
}
};
for entry in entries.flatten() {
let entry_path = entry.path();
if !entry_path.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
// 匹配项目名
let project_name = path_map
.get(&dir_name)
.cloned()
.unwrap_or_else(|| {
// 尝试从目录名提取最后一段作为项目名
dir_name.rsplit('-').next().unwrap_or(&dir_name).to_string()
});
let decoded_path = decode_dir_name(&dir_name);
// 扫描 JSONL 文件
let jsonl_files: Vec<PathBuf> = match fs::read_dir(&entry_path) {
Ok(entries) => entries
.flatten()
.filter(|e| {
e.path()
.extension()
.is_some_and(|ext| ext == "jsonl")
})
.map(|e| e.path())
.collect(),
Err(_) => continue,
};
if jsonl_files.is_empty() {
continue;
}
let usage = project_usages.entry(dir_name.clone()).or_insert(ProjectUsage {
name: project_name,
path: decoded_path,
session_count: 0,
message_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_create_tokens: 0,
last_active: String::new(),
models: vec![],
});
let models = project_models.entry(dir_name.clone()).or_default();
for jsonl_path in &jsonl_files {
let file = match fs::File::open(jsonl_path) {
Ok(f) => f,
Err(_) => continue,
};
let mut session_has_data = false;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
// 快速过滤:只解析包含 "type":"assistant" 的行
if !line.contains("\"type\":\"assistant\"") {
continue;
}
let parsed: JLine = match serde_json::from_str(&line) {
Ok(p) => p,
Err(_) => continue,
};
if parsed.line_type.as_deref() != Some("assistant") {
continue;
}
// 时间范围过滤
if let Some(ref cutoff_str) = cutoff {
if let Some(ref ts) = parsed.timestamp {
if ts.as_str() < cutoff_str.as_str() {
continue;
}
}
}
if let Some(msg) = parsed.message {
if let Some(u) = msg.usage {
session_has_data = true;
usage.message_count += 1;
let out_tokens = u.output_tokens.unwrap_or(0);
usage.input_tokens += u.input_tokens.unwrap_or(0);
usage.output_tokens += out_tokens;
usage.cache_read_tokens += u.cache_read_input_tokens.unwrap_or(0);
usage.cache_create_tokens += u.cache_creation_input_tokens.unwrap_or(0);
// 按模型聚合
if let Some(ref model_id) = msg.model {
let entry = models.entry(model_id.clone()).or_insert((0, 0));
entry.0 += 1;
entry.1 += out_tokens;
}
// 更新最后活跃时间
if let Some(ref ts) = parsed.timestamp {
if ts.as_str() > usage.last_active.as_str() {
usage.last_active = ts.clone();
}
}
}
}
}
if session_has_data {
usage.session_count += 1;
}
}
}
// 将模型数据填入项目
for (dir_name, model_map) in &project_models {
if let Some(usage) = project_usages.get_mut(dir_name) {
let mut model_list: Vec<ModelUsage> = model_map
.iter()
.map(|(model_id, (count, tokens))| ModelUsage {
model: model_id.clone(),
display_name: format_model_name(model_id),
message_count: *count,
output_tokens: *tokens,
})
.collect();
// 按 output_tokens 降序
model_list.sort_by(|a, b| b.output_tokens.cmp(&a.output_tokens));
usage.models = model_list;
}
}
// 收集并排序(按 output_tokens 降序)
let mut projects: Vec<ProjectUsage> = project_usages
.into_values()
.filter(|p| p.message_count > 0)
.collect();
projects.sort_by(|a, b| b.output_tokens.cmp(&a.output_tokens));
let total_sessions = projects.iter().map(|p| p.session_count).sum();
let total_messages = projects.iter().map(|p| p.message_count).sum();
let total_input_tokens = projects.iter().map(|p| p.input_tokens).sum();
let total_output_tokens = projects.iter().map(|p| p.output_tokens).sum();
UsageStats {
projects,
total_sessions,
total_messages,
total_input_tokens,
total_output_tokens,
}
}
+25 -5
View File
@@ -10,16 +10,36 @@
<div class="container">
<header>
<h1>Claude Hub</h1>
<div class="tabs">
<button class="tab active" data-tab="projects" onclick="switchTab('projects')">项目</button>
<button class="tab" data-tab="usage" onclick="switchTab('usage')">用量</button>
</div>
<span class="status-badge" id="globalStatus">就绪</span>
</header>
<div class="search-box">
<input type="text" id="searchInput" placeholder="搜索项目..." autocomplete="off" spellcheck="false">
<!-- 项目视图 -->
<div id="projectsView" class="view active">
<div class="search-box">
<input type="text" id="searchInput" placeholder="搜索项目..." autocomplete="off" spellcheck="false">
</div>
<section class="projects" id="projectList">
<div class="empty">加载中...</div>
</section>
</div>
<section class="projects" id="projectList">
<div class="empty">加载中...</div>
</section>
<!-- 用量视图 -->
<div id="usageView" class="view" style="display:none">
<div class="usage-summary" id="usageSummary"></div>
<div class="usage-range">
<button class="range-btn" data-range="7d" onclick="loadUsage('7d')">7天</button>
<button class="range-btn" data-range="30d" onclick="loadUsage('30d')">30天</button>
<button class="range-btn active" data-range="all" onclick="loadUsage('all')">全部</button>
</div>
<div class="usage-list" id="usageList">
<div class="empty">加载中...</div>
</div>
</div>
<section class="events">
<h2>最近通知</h2>
+124 -1
View File
@@ -8,6 +8,9 @@ const state = {
events: [],
menuTarget: null, // 当前弹出菜单的项目
searchQuery: '',
currentTab: 'projects',
usageRange: 'all',
usageData: null,
};
async function init() {
@@ -46,7 +49,9 @@ async function init() {
async function refreshProjects() {
try {
state.projects = await invoke('get_projects');
renderProjects();
if (state.currentTab === 'projects') {
renderProjects();
}
updateGlobalStatus();
} catch (e) {}
}
@@ -66,6 +71,100 @@ function handleEvent(data) {
updateGlobalStatus();
}
// Tab 切换
function switchTab(tab) {
state.currentTab = tab;
document.querySelectorAll('.tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === tab);
});
document.getElementById('projectsView').style.display = tab === 'projects' ? '' : 'none';
document.getElementById('usageView').style.display = tab === 'usage' ? '' : 'none';
if (tab === 'usage' && !state.usageData) {
loadUsage(state.usageRange);
}
}
// 加载用量数据
async function loadUsage(range) {
state.usageRange = range;
// 更新按钮状态
document.querySelectorAll('.range-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.range === range);
});
document.getElementById('usageList').innerHTML = '<div class="empty">统计中...</div>';
try {
state.usageData = await invoke('get_usage_stats', { range });
renderUsage();
} catch (e) {
document.getElementById('usageList').innerHTML = '<div class="empty">加载失败</div>';
}
}
function renderUsage() {
const data = state.usageData;
if (!data) return;
// 汇总
const summary = document.getElementById('usageSummary');
summary.innerHTML = `
<div class="summary-item">
<span class="summary-value">${data.total_sessions}</span>
<span class="summary-label">会话</span>
</div>
<div class="summary-item">
<span class="summary-value">${formatNum(data.total_messages)}</span>
<span class="summary-label">消息</span>
</div>
<div class="summary-item">
<span class="summary-value">${formatTokens(data.total_input_tokens)}</span>
<span class="summary-label">输入</span>
</div>
<div class="summary-item">
<span class="summary-value">${formatTokens(data.total_output_tokens)}</span>
<span class="summary-label">输出</span>
</div>
`;
// 排行榜
const list = document.getElementById('usageList');
if (data.projects.length === 0) {
list.innerHTML = '<div class="empty">无数据</div>';
return;
}
const maxTokens = data.projects[0].output_tokens || 1;
list.innerHTML = data.projects.map(p => {
const pct = Math.max(2, (p.output_tokens / maxTokens) * 100);
const lastTime = p.last_active ? formatDate(p.last_active) : '';
const modelTags = (p.models || []).map(m => {
return `<span class="model-tag" title="${m.model}">${m.display_name} <em>${formatTokens(m.output_tokens)}</em></span>`;
}).join('');
return `
<div class="usage-item">
<div class="usage-row">
<span class="usage-name">${p.name}</span>
<span class="usage-tokens">${formatTokens(p.output_tokens)}</span>
</div>
<div class="usage-bar-bg">
<div class="usage-bar" style="width:${pct}%"></div>
</div>
<div class="usage-meta">
<span>${p.session_count} 会话 · ${formatNum(p.message_count)} 消息</span>
<span>${lastTime}</span>
</div>
${modelTags ? `<div class="usage-models">${modelTags}</div>` : ''}
</div>
`;
}).join('');
}
function renderProjects() {
const list = document.getElementById('projectList');
@@ -192,6 +291,28 @@ function formatTime(ts) {
}
}
function formatDate(ts) {
try {
const d = new Date(ts);
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
} catch {
return '';
}
}
function formatNum(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return String(n);
}
function formatTokens(n) {
if (n >= 1000000000) return (n / 1000000000).toFixed(1) + 'B';
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return String(n);
}
function updateGlobalStatus() {
const badge = document.getElementById('globalStatus');
const hasRunning = state.projects.some(p => p.running);
@@ -212,5 +333,7 @@ function updateGlobalStatus() {
// 暴露给 HTML onclick
window.menuAction = menuAction;
window.switchTab = switchTab;
window.loadUsage = loadUsage;
init();
+172
View File
@@ -46,6 +46,43 @@ header h1 {
.status-badge.busy { background: #2d3a5e; color: #60a5fa; }
.status-badge.attention { background: #4a2d2d; color: #f87171; }
/* Tabs */
.tabs {
display: flex;
gap: 2px;
background: #16162a;
border-radius: 6px;
padding: 2px;
}
.tab {
border: none;
background: transparent;
color: #666;
font-size: 12px;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.tab:hover {
color: #aaa;
}
.tab.active {
background: #2a2a4a;
color: #e0e0e0;
}
/* Views */
.view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 搜索框 */
.search-box {
margin-bottom: 8px;
@@ -190,6 +227,141 @@ header h1 {
cursor: default;
}
/* Usage 视图 */
.usage-summary {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.summary-item {
flex: 1;
text-align: center;
padding: 8px 4px;
background: #16162a;
border-radius: 6px;
}
.summary-value {
display: block;
font-size: 16px;
font-weight: 600;
color: #fff;
font-variant-numeric: tabular-nums;
}
.summary-label {
font-size: 10px;
color: #666;
margin-top: 2px;
}
.usage-range {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.range-btn {
border: 1px solid #2a2a4a;
background: transparent;
color: #666;
font-size: 11px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.range-btn:hover {
color: #aaa;
border-color: #3a3a5a;
}
.range-btn.active {
background: #2a2a4a;
color: #e0e0e0;
border-color: #3a3a5a;
}
.usage-list {
flex: 1;
overflow-y: auto;
margin-bottom: 12px;
}
.usage-item {
padding: 6px 0;
border-bottom: 1px solid #1e1e38;
}
.usage-item:last-child {
border-bottom: none;
}
.usage-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.usage-name {
font-size: 12px;
font-weight: 500;
color: #e0e0e0;
}
.usage-tokens {
font-size: 12px;
font-weight: 600;
color: #60a5fa;
font-variant-numeric: tabular-nums;
}
.usage-bar-bg {
height: 4px;
background: #1e1e38;
border-radius: 2px;
margin-bottom: 3px;
}
.usage-bar {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #60a5fa);
border-radius: 2px;
transition: width 0.3s;
}
.usage-meta {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #555;
}
.usage-models {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.model-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: #1e2a3a;
color: #8bb4e0;
border: 1px solid #2a3a4e;
}
.model-tag em {
font-style: normal;
color: #557;
margin-left: 2px;
}
/* 通知列表 */
.events {
border-top: 1px solid #2a2a4a;
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# 编译 Claude Hub 托盘 app(release 版本)
cd ~/Documents/code/claude-hub/app/src-tauri
cargo build --release
echo "✓ 编译完成: target/release/claude-hub"
+6 -6
View File
@@ -2,21 +2,21 @@
# cc - Claude Hub 一键启动
# 安装: 在 ~/.zshrc 中添加 alias cc="bash ~/Documents/code/claude-hub/scripts/cc.sh"
#
# 做三件事:
# 做两件事:
# 1. 启动 Claude Hub 托盘 app(如果没在跑)
# 2. 在 claude-hub 目录启动 claude(调度中心)
HUB_DIR="$HOME/Documents/code/claude-hub"
APP_BIN="$HUB_DIR/app/claude-hub-bin"
APP_RELEASE="$HUB_DIR/app/src-tauri/target/release/claude-hub"
# 1. 启动托盘 app
if ! pgrep -f "claude-hub-bin" >/dev/null 2>&1; then
if [ -f "$APP_BIN" ]; then
"$APP_BIN" &
if ! pgrep -f "claude-hub" >/dev/null 2>&1; then
if [ -f "$APP_RELEASE" ]; then
"$APP_RELEASE" &
disown
echo "✓ Claude Hub 托盘已启动"
else
echo "⚠ 托盘 app 未编译,跳过"
echo "⚠ 未找到编译产物,请先执行: cd $HUB_DIR/app && cargo build --release"
fi
else
echo "✓ Claude Hub 托盘已在运行"