diff --git a/bun.lock b/bun.lock index abf45635..b2b5826e 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "cpa-gui", "dependencies": { "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.0.0", "lucide-react": "^1.24.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -199,6 +200,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], diff --git a/package.json b/package.json index 9a905ed6..cb126544 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.0.0", "lucide-react": "^1.24.0", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a5b322df..fa021ee4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -617,6 +617,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-dialog", "tauri-plugin-opener", "tokio", "tokio-util", @@ -2519,6 +2520,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -3211,6 +3213,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4061,6 +4087,48 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.3+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -5208,6 +5276,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -5241,13 +5318,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -5278,6 +5372,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5290,6 +5390,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5302,12 +5408,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5320,6 +5438,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5332,6 +5456,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5344,6 +5474,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5356,6 +5492,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6ff7b58f..d524c002 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ sha2 = "0.10" tar = "0.4" tauri = { version = "~2.11", features = ["tray-icon"] } tauri-plugin-autostart = "2" +tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tokio = { version = "1", features = ["macros", "sync", "time"] } tokio-util = "0.7" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0a29d285..262e7b35 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "core:event:allow-unlisten", "core:window:allow-set-theme", "core:window:allow-set-background-color", - "core:webview:allow-set-webview-background-color" + "core:webview:allow-set-webview-background-color", + "dialog:allow-save" ] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b8153953..b958024e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1648,6 +1648,7 @@ fn main() { let start_hidden = should_start_hidden(&gui_config); let app = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, None, @@ -1879,6 +1880,7 @@ fn main() { usage::get_usage_overview, usage::get_usage_analysis, usage::get_usage_events, + usage::export_usage_records, usage::get_usage_pricing, usage::save_usage_model_price, usage::delete_usage_model_price, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index e3786b25..b9169f22 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -139,6 +139,29 @@ pub(crate) struct UsageRecord { tokens: UsageTokenStats, } +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum UsageExportFormat { + Csv, + Json, +} + +impl UsageExportFormat { + fn extension(self) -> &'static str { + match self { + Self::Csv => "csv", + Self::Json => "json", + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageExportResult { + path: String, + record_count: usize, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct LegacyUsageHourFile { @@ -1818,6 +1841,190 @@ pub(crate) fn get_usage_events( load_usage_events(&connection, &query, &config) } +#[tauri::command] +pub(crate) fn export_usage_records( + path: String, + format: UsageExportFormat, + query: UsageQuery, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result { + let connection = open_usage_database()?; + let config = gui_config_state.snapshot()?; + let records = load_usage_records_for_export(&connection, &query, &config)?; + let path = normalized_usage_export_path(&path, format)?; + let content = match format { + UsageExportFormat::Csv => render_usage_records_csv(&records).into_bytes(), + UsageExportFormat::Json => serde_json::to_vec_pretty(&records) + .map_err(|error| format!("Failed to serialize usage records as JSON: {error}"))?, + }; + fs::write(&path, content).map_err(|error| { + format!( + "Failed to write usage export {}: {error}", + path.to_string_lossy() + ) + })?; + Ok(UsageExportResult { + path: path.to_string_lossy().into_owned(), + record_count: records.len(), + }) +} + +fn normalized_usage_export_path(value: &str, format: UsageExportFormat) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("Usage export path is empty".to_string()); + } + let mut path = PathBuf::from(value); + if path.file_name().is_none() { + return Err("Usage export path does not contain a file name".to_string()); + } + if path.extension().and_then(|value| value.to_str()) != Some(format.extension()) { + path.set_extension(format.extension()); + } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + if !parent.is_dir() { + return Err(format!( + "Usage export directory does not exist: {}", + parent.to_string_lossy() + )); + } + Ok(path) +} + +fn load_usage_records_for_export( + connection: &Connection, + query: &UsageQuery, + config: &GuiConfigFile, +) -> Result, String> { + let filter = build_usage_filter(query); + let sql = format!( + r#" + SELECT + event_key, timestamp, latency_ms, ttft_ms, source, auth_index, failed, + provider, model, alias, reasoning_effort, service_tier, + response_service_tier, executor_type, endpoint, auth_type, + api_key_hash, api_key_display, api_key_remark, request_id, + input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, + cache_creation_tokens, total_tokens + FROM usage_events{} + ORDER BY timestamp_ms DESC, id DESC + "#, + filter.clause + ); + let mut statement = connection + .prepare(&sql) + .map_err(|error| format!("Failed to prepare usage export query: {error}"))?; + let mut records = statement + .query_map( + params_from_iter(filter.params.iter()), + usage_record_from_row, + ) + .map_err(|error| format!("Failed to query usage records for export: {error}"))? + .collect::, _>>() + .map_err(|error| format!("Failed to read usage records for export: {error}"))?; + for record in &mut records { + record.source_display = usage_source_display(config, &record.provider, &record.source); + } + Ok(records) +} + +fn render_usage_records_csv(records: &[UsageRecord]) -> String { + const HEADERS: [&str; 27] = [ + "id", + "timestamp", + "result", + "latency_ms", + "ttft_ms", + "provider", + "model", + "alias", + "source_display", + "source", + "auth_index", + "auth_type", + "api_key_display", + "api_key_remark", + "api_key_hash", + "endpoint", + "reasoning_effort", + "service_tier", + "response_service_tier", + "executor_type", + "request_id", + "input_tokens", + "output_tokens", + "reasoning_tokens", + "cache_read_tokens", + "cache_creation_tokens", + "total_tokens", + ]; + + let mut output = String::from('\u{feff}'); + append_csv_row(&mut output, HEADERS.iter().copied()); + for record in records { + let ttft_ms = record + .ttft_ms + .map(|value| value.to_string()) + .unwrap_or_default(); + append_csv_row( + &mut output, + [ + record.id.clone(), + record.timestamp.clone(), + if record.failed { "failed" } else { "success" }.to_string(), + record.latency_ms.to_string(), + ttft_ms, + record.provider.clone(), + record.model.clone(), + record.alias.clone(), + record.source_display.clone(), + record.source.clone(), + record.auth_index.clone(), + record.auth_type.clone(), + record.api_key_display.clone(), + record.api_key_remark.clone(), + record.api_key_hash.clone(), + record.endpoint.clone(), + record.reasoning_effort.clone(), + record.service_tier.clone(), + record.response_service_tier.clone(), + record.executor_type.clone(), + record.request_id.clone(), + record.tokens.input_tokens.to_string(), + record.tokens.output_tokens.to_string(), + record.tokens.reasoning_tokens.to_string(), + record.tokens.cache_read_tokens.to_string(), + record.tokens.cache_creation_tokens.to_string(), + record.tokens.total_tokens.to_string(), + ], + ); + } + output +} + +fn append_csv_row(output: &mut String, fields: I) +where + I: IntoIterator, + S: AsRef, +{ + let mut first = true; + for field in fields { + if !first { + output.push(','); + } + first = false; + let field = field.as_ref(); + if field.contains([',', '"', '\r', '\n']) { + output.push('"'); + output.push_str(&field.replace('"', "\"\"")); + output.push('"'); + } else { + output.push_str(field); + } + } + output.push_str("\r\n"); +} + fn load_usage_events( connection: &Connection, query: &UsageQuery, @@ -2424,6 +2631,84 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn usage_export_query_applies_filters_without_pagination() { + let root = test_root("export-query"); + let mut connection = open_test_database(&root); + let first = sample_record("request-1", "2026-07-17T20:30:00+08:00", "gpt-a"); + let second = sample_record("request-2", "2026-07-17T21:30:00+08:00", "gpt-a"); + let mut failed = sample_record("request-3", "2026-07-17T22:30:00+08:00", "gpt-a"); + failed.failed = true; + insert_usage_records(&mut connection, &[first, second, failed]).unwrap(); + + let records = load_usage_records_for_export( + &connection, + &UsageQuery { + model: Some("GPT-A".to_string()), + failed: Some(false), + page: Some(2), + page_size: Some(1), + ..UsageQuery::default() + }, + &GuiConfigFile::default(), + ) + .unwrap(); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].id, "request-2"); + assert_eq!(records[1].id, "request-1"); + assert!(records.iter().all(|record| !record.failed)); + assert!(records + .iter() + .all(|record| record.source_display == "source")); + drop(connection); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn csv_export_is_excel_friendly_and_escapes_special_characters() { + let mut record = sample_record( + "request-1", + "2026-07-17T20:30:00+08:00", + "模型, \"A\"\n下一行", + ); + record.api_key_remark = "繁體中文".to_string(); + let csv = render_usage_records_csv(&[record]); + + assert!(csv.starts_with('\u{feff}')); + assert!(csv.starts_with("\u{feff}id,timestamp,result,")); + assert!(csv.contains("\"模型, \"\"A\"\"\n下一行\"")); + assert!(csv.contains("繁體中文")); + assert!(csv.ends_with("\r\n")); + } + + #[test] + fn export_path_adds_the_selected_extension() { + let root = test_root("export-path"); + let base = root.join("usage-history"); + let csv = + normalized_usage_export_path(base.to_string_lossy().as_ref(), UsageExportFormat::Csv) + .unwrap(); + let explicit_json = root.join("usage-history.json"); + let json = normalized_usage_export_path( + explicit_json.to_string_lossy().as_ref(), + UsageExportFormat::Json, + ) + .unwrap(); + + assert_eq!(csv, base.with_extension("csv")); + assert_eq!(json, explicit_json); + assert_eq!( + normalized_usage_export_path( + explicit_json.to_string_lossy().as_ref(), + UsageExportFormat::Csv, + ) + .unwrap(), + explicit_json.with_extension("csv") + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn estimates_gpt56_cost_with_cache_and_service_tier_rules() { let terra = official_model_price("openai/gpt-5.6-terra").unwrap(); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 8fbecaab..602dedb9 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -129,6 +129,13 @@ export const jaOverrides = { 'usage.filter.endTime': '終了日時', 'usage.filter.to': '~', 'usage.loading': 'ローカル SQLite の使用履歴を読み込んでいます', + 'usage.export.action': 'エクスポート', + 'usage.export.csv': 'CSV をエクスポート', + 'usage.export.json': 'JSON をエクスポート', + 'usage.export.exporting': 'エクスポート中', + 'usage.export.dialogTitle': '使用履歴をエクスポート', + 'usage.export.success': '{count} 件を {path} にエクスポートしました', + 'usage.export.failed': '使用履歴のエクスポートに失敗しました:{error}', 'usage.stat.requests': '総リクエスト数', 'usage.stat.requestMeta': '成功 {success} · 失敗 {failed}', 'usage.stat.tokens': '合計 Token', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e39efc9b..d9656329 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -129,6 +129,13 @@ export const en: Record = { 'usage.filter.endTime': 'End time', 'usage.filter.to': 'to', 'usage.loading': 'Loading local SQLite usage history', + 'usage.export.action': 'Export', + 'usage.export.csv': 'Export CSV', + 'usage.export.json': 'Export JSON', + 'usage.export.exporting': 'Exporting', + 'usage.export.dialogTitle': 'Export Usage History', + 'usage.export.success': 'Exported {count} records to {path}', + 'usage.export.failed': 'Failed to export usage history: {error}', 'usage.stat.requests': 'Total Requests', 'usage.stat.requestMeta': 'OK {success} · Fail {failed}', 'usage.stat.tokens': 'Total Tokens', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 9ecde0d6..4a73b3d5 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -127,6 +127,13 @@ export const zhCN = { 'usage.filter.endTime': '结束时间', 'usage.filter.to': '至', 'usage.loading': '正在读取本地 SQLite 使用记录', + 'usage.export.action': '导出', + 'usage.export.csv': '导出 CSV', + 'usage.export.json': '导出 JSON', + 'usage.export.exporting': '正在导出', + 'usage.export.dialogTitle': '导出使用记录', + 'usage.export.success': '已导出 {count} 条记录至 {path}', + 'usage.export.failed': '导出使用记录失败:{error}', 'usage.stat.requests': '请求总数', 'usage.stat.requestMeta': '成功 {success} · 失败 {failed}', 'usage.stat.tokens': 'Token 总量', diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index 24a0c0a6..88032252 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -1,12 +1,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; -import { Activity, BarChart3, CircleDollarSign, Clock3, Database, List, Pencil, RefreshCw, ShieldCheck, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'; +import { save } from '@tauri-apps/plugin-dialog'; +import { Activity, BarChart3, ChevronDown, CircleDollarSign, Clock3, Database, Download, List, Pencil, RefreshCw, ShieldCheck, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'; import { getCurrentLocale, useI18n } from '../i18n'; import { formatUsageNumber } from '../services/usageNumber'; type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing'; type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom'; +type UsageExportFormat = 'csv' | 'json'; + +type UsageExportResult = { + path: string; + recordCount: number; +}; type CollectorStatus = { state: 'waiting-core' | 'collecting' | 'error'; @@ -238,6 +245,10 @@ export function UsageRecordsPage() { const [pricing, setPricing] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [exporting, setExporting] = useState(null); + const [exportMenuOpen, setExportMenuOpen] = useState(false); + const [exportNotice, setExportNotice] = useState(''); + const exportMenuRef = useRef(null); const requestIdRef = useRef(0); useEffect(() => { @@ -248,6 +259,26 @@ export function UsageRecordsPage() { try { localStorage.setItem(RANGE_KEY, range); } catch { /* Keep the in-memory range. */ } }, [range]); + useEffect(() => { + if (!exportMenuOpen) return; + + const closeOnOutsideClick = (event: PointerEvent) => { + if (!exportMenuRef.current?.contains(event.target as Node)) { + setExportMenuOpen(false); + } + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setExportMenuOpen(false); + }; + + document.addEventListener('pointerdown', closeOnOutsideClick); + document.addEventListener('keydown', closeOnEscape); + return () => { + document.removeEventListener('pointerdown', closeOnOutsideClick); + document.removeEventListener('keydown', closeOnEscape); + }; + }, [exportMenuOpen]); + const buildQueries = useCallback(() => { const nextTimeQuery = rangeQuery(range, customStart, customEnd); return { @@ -356,6 +387,35 @@ export function UsageRecordsPage() { setPage(1); }; + const exportUsageRecords = async (format: UsageExportFormat) => { + if (exporting) return; + setExporting(format); + setError(''); + setExportNotice(''); + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const path = await save({ + title: t('usage.export.dialogTitle'), + defaultPath: `easycliproxy-usage-${timestamp}.${format}`, + filters: [{ name: format.toUpperCase(), extensions: [format] }], + }); + if (!path) return; + const exported = await invoke('export_usage_records', { + path, + format, + query: buildQueries().query, + }); + setExportNotice(t('usage.export.success', { + count: compactNumber(exported.recordCount), + path: exported.path, + })); + } catch (exportError) { + setError(t('usage.export.failed', { error: String(exportError) })); + } finally { + setExporting(null); + } + }; + const collectorTone = status?.state === 'error' ? 'error' : status?.state === 'collecting' ? 'success' : ''; const showInitialLoading = loading && ( (activeTab === 'overview' && !overview) @@ -371,16 +431,39 @@ export function UsageRecordsPage() { Local Usage

{t('usage.title')}

-
- -
- {status?.state === 'collecting' ? t('usage.collector.collecting') : status?.state === 'error' ? t('usage.collector.error') : t('usage.collector.waiting')} - {t('usage.longTermRecords', { count: compactNumber(status?.totalRecords ?? 0) })} +
+
+ + {exportMenuOpen ? ( +
+ + +
+ ) : null} +
+
+ +
+ {status?.state === 'collecting' ? t('usage.collector.collecting') : status?.state === 'error' ? t('usage.collector.error') : t('usage.collector.waiting')} + {t('usage.longTermRecords', { count: compactNumber(status?.totalRecords ?? 0) })} +
{error ?
{error}
: null} + {exportNotice ?
{exportNotice}
: null}
diff --git a/src/styles.css b/src/styles.css index c8950abc..61de4d20 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5972,6 +5972,73 @@ textarea:focus-visible { min-height: 54px; } +.usage-header-actions, +.usage-export-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.usage-export-actions { + position: relative; +} + +.usage-header-actions { + flex-wrap: wrap; + justify-content: flex-end; + gap: 12px; +} + +.usage-export-actions .secondary-button { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.usage-export-trigger svg:last-child { + transition: transform 160ms ease; +} + +.usage-export-trigger svg:last-child.is-open { + transform: rotate(180deg); +} + +.usage-export-menu { + position: absolute; + z-index: 20; + top: calc(100% + 6px); + right: 0; + display: grid; + min-width: 148px; + padding: 5px; + border: 1px solid var(--theme-e3e1db); + border-radius: 9px; + background: var(--theme-fffdf8); + box-shadow: 0 12px 28px rgba(49, 45, 39, 0.14); +} + +.usage-export-menu button { + border: 0; + border-radius: 6px; + padding: 8px 10px; + background: transparent; + color: var(--theme-2d2a26); + font: inherit; + text-align: left; + cursor: pointer; +} + +.usage-export-menu button:hover, +.usage-export-menu button:focus-visible { + background: var(--theme-f4f1ea); + outline: none; +} + +.usage-export-notice { + word-break: break-all; +} + .usage-collector-state { display: flex; align-items: center;