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:
1 parent
0bca945994
commit
ffa18e2575
10 files changed
+660
-15
No files matched your search
@@ -8,4 +8,3 @@ sounds/
|
||||
projects.yaml
|
||||
app/src-tauri/target/
|
||||
app/node_modules/
|
||||
app/claude-hub-bin
|
||||
@@ -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`,报告状态
|
||||
|
||||
@@ -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
|
||||
])
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
Executable
+5
@@ -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
@@ -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 托盘已在运行"
|
||||
|
||||
Reference in new issue
Block a user