From 44dcdf13a868c9913c154012e78d92d65bd8e347 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 28 Aug 2026 00:30:40 +0800 Subject: [PATCH] feat(usage): add manual historical cache data repair --- src-tauri/src/main.rs | 1 + src-tauri/src/usage.rs | 72 ++++++++++++------------ src/i18n/ja.ts | 13 +++++ src/i18n/locales/en.ts | 13 +++++ src/i18n/locales/zh-CN.ts | 13 +++++ src/pages/UsageRecordsPage.tsx | 91 +++++++++++++++++++++++++++++- src/styles.css | 100 +++++++++++++++++++++++++++++++++ 7 files changed, 263 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9b1aa16..e633c4b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2452,6 +2452,7 @@ fn main() { usage::get_usage_analysis, usage::get_usage_events, usage::get_usage_pricing, + usage::repair_usage_cache_records, usage::save_usage_model_price, usage::delete_usage_model_price, usage::sync_usage_model_prices, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 14b823c..cf848ff 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -35,7 +35,6 @@ const LEGACY_USAGE_EVENTS_DIR: &str = "events"; const LEGACY_USAGE_INBOX_DIR: &str = "inbox"; const LEGACY_JSON_MIGRATION_KEY: &str = "legacy_json_v1"; const USAGE_DATABASE_MIGRATION_KEY: &str = "keeper_v3"; -const CLAUDE_INPUT_MIGRATION_KEY: &str = "claude_input_inclusive_v1"; const USAGE_FAILURE_MIGRATION_KEY: &str = "failure_details_v4"; const USAGE_UPDATED_EVENT: &str = "usage-records-updated"; const USAGE_SCHEMA_VERSION: u8 = 1; @@ -266,6 +265,14 @@ pub(crate) struct UsageOverview { timeline: Vec, } +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageRepairResult { + scanned: u64, + repaired: u64, + backup_path: Option, +} + #[derive(Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ModelPrice { @@ -529,24 +536,18 @@ fn initialize_usage_storage_at(root: &Path) -> Result<(), String> { .pragma_update(None, "journal_mode", "WAL") .map_err(|error| format!("启用 SQLite WAL 失败: {error}"))?; migrate_usage_database(&mut connection, root)?; - migrate_claude_input_tokens(&mut connection, root)?; migrate_legacy_json_storage(&mut connection, root)?; cleanup_usage_inbox(&connection, Local::now()) } -fn migrate_claude_input_tokens(connection: &mut Connection, root: &Path) -> Result<(), String> { - let already_done: Option = connection - .query_row( - "SELECT value FROM usage_metadata WHERE key = ?1", - params![CLAUDE_INPUT_MIGRATION_KEY], - |row| row.get(0), - ) - .optional() - .map_err(|error| format!("检查 Claude 使用记录迁移状态失败: {error}"))?; - if already_done.is_some() { - return Ok(()); - } +#[tauri::command] +pub(crate) fn repair_usage_cache_records() -> Result { + let root = usage_root_dir()?; + repair_usage_cache_records_at(&root) +} +fn repair_usage_cache_records_at(root: &Path) -> Result { + let mut connection = open_usage_database_at(root)?; let candidate_count: i64 = connection .query_row( r#"SELECT COUNT(*) FROM usage_events @@ -560,7 +561,11 @@ fn migrate_claude_input_tokens(connection: &mut Connection, root: &Path) -> Resu ) .map_err(|error| format!("检查 Claude 使用记录异常行失败: {error}"))?; - if candidate_count > 0 { + if candidate_count <= 0 { + return Ok(UsageRepairResult::default()); + } + + let backup_path = { let backup_dir = root.join(USAGE_BACKUP_DIR_NAME); fs::create_dir_all(&backup_dir) .map_err(|error| format!("创建 Claude 使用记录迁移备份目录失败: {error}"))?; @@ -574,7 +579,8 @@ fn migrate_claude_input_tokens(connection: &mut Connection, root: &Path) -> Resu params![backup_path.to_string_lossy().to_string()], ) .map_err(|error| format!("备份 Claude 使用记录失败: {error}"))?; - } + backup_path.to_string_lossy().to_string() + }; let transaction = connection .transaction() @@ -598,21 +604,14 @@ fn migrate_claude_input_tokens(connection: &mut Connection, root: &Path) -> Resu [], ) .map_err(|error| format!("迁移 Claude 使用记录失败: {error}"))?; - transaction - .execute( - "INSERT INTO usage_metadata (key, value) VALUES (?1, ?2) - ON CONFLICT(key) DO UPDATE SET value = excluded.value", - params![CLAUDE_INPUT_MIGRATION_KEY, Local::now().to_rfc3339()], - ) - .map_err(|error| format!("写入 Claude 使用记录迁移标记失败: {error}"))?; transaction .commit() .map_err(|error| format!("提交 Claude 使用记录迁移失败: {error}"))?; - - if migrated > 0 { - eprintln!("migrated {migrated} Claude usage rows to inclusive input token semantics"); - } - Ok(()) + Ok(UsageRepairResult { + scanned: candidate_count.max(0) as u64, + repaired: migrated as u64, + backup_path: Some(backup_path), + }) } fn migrate_usage_database(connection: &mut Connection, root: &Path) -> Result<(), String> { @@ -3301,7 +3300,7 @@ mod tests { } #[test] - fn migrates_legacy_claude_input_tokens_once() { + fn repairs_legacy_claude_input_tokens_on_demand() { let root = test_root("claude-input-migration"); let connection = open_test_database(&root); connection @@ -3316,15 +3315,12 @@ mod tests { [], ) .unwrap(); - connection - .execute( - "DELETE FROM usage_metadata WHERE key = ?1", - params![CLAUDE_INPUT_MIGRATION_KEY], - ) - .unwrap(); drop(connection); - initialize_usage_storage_at(&root).unwrap(); + let result = repair_usage_cache_records_at(&root).unwrap(); + assert_eq!(result.scanned, 1); + assert_eq!(result.repaired, 1); + assert!(result.backup_path.is_some()); let connection = open_usage_database_at(&root).unwrap(); let row: (i64, i64, i64) = connection .query_row( @@ -3336,7 +3332,9 @@ mod tests { assert_eq!(row, (720, 740, 620)); drop(connection); - initialize_usage_storage_at(&root).unwrap(); + let second = repair_usage_cache_records_at(&root).unwrap(); + assert_eq!(second.scanned, 0); + assert_eq!(second.repaired, 0); let rerun_connection = open_usage_database_at(&root).unwrap(); let rerun_input: i64 = rerun_connection .query_row( diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 5d4fa5b..2b6e2c2 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -113,6 +113,7 @@ export const jaOverrides = { 'usage.tab.analysis': '分析', 'usage.tab.events': 'リクエスト詳細', 'usage.tab.pricing': '料金統計', + 'usage.tab.dataManagement': 'データ管理', 'usage.refresh': '更新', 'usage.filter.reset': 'フィルター解除', 'usage.filter.timeRange': '期間', @@ -179,6 +180,18 @@ export const jaOverrides = { 'usage.pricing.saved': 'モデル料金を保存し、概要のコストを更新しました', 'usage.pricing.deleted': 'モデル料金を削除し、概要のコストを更新しました', 'usage.pricing.deleteConfirm': 'モデル {model} の料金を削除しますか?', + 'usage.dataManagement.title': '履歴データ管理', + 'usage.dataManagement.description': 'キャッシュ Token の不整合がある使用履歴をスキャンして修復します。', + 'usage.dataManagement.manualBadge': '手動操作', + 'usage.dataManagement.notice': '修復前にデータベースをバックアップします。判定可能な Claude / Anthropic の記録のみ処理します。', + 'usage.dataManagement.actionTitle': 'キャッシュ記録を修復', + 'usage.dataManagement.actionDescription': 'キャッシュ読み取りと作成を含む入力 Token の集計方式に統一します。', + 'usage.dataManagement.run': 'スキャンして修復', + 'usage.dataManagement.running': '処理中…', + 'usage.dataManagement.confirm': '履歴キャッシュ記録をスキャンして修復しますか?先にデータベースをバックアップします。', + 'usage.dataManagement.scanned': 'スキャン件数', + 'usage.dataManagement.repaired': '修復件数', + 'usage.dataManagement.backup': 'バックアップ', 'usage.trend.title': 'リクエストと Token の推移', 'usage.trend.description': 'システムのローカル時刻ごとに集計', 'usage.token.title': 'Token の内訳', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 377430a..18edd1f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -113,6 +113,7 @@ export const en: Record = { 'usage.tab.analysis': 'Analysis', 'usage.tab.events': 'Request Details', 'usage.tab.pricing': 'Price Statistics', + 'usage.tab.dataManagement': 'Data Management', 'usage.refresh': 'Refresh', 'usage.filter.reset': 'Reset Filters', 'usage.filter.timeRange': 'Time range', @@ -179,6 +180,18 @@ export const en: Record = { 'usage.pricing.saved': 'Model price saved; overview cost updated', 'usage.pricing.deleted': 'Model price deleted; overview cost updated', 'usage.pricing.deleteConfirm': 'Delete the price for {model}?', + 'usage.dataManagement.title': 'Historical Data Management', + 'usage.dataManagement.description': 'Scan and repair historical usage records with inconsistent cache token accounting.', + 'usage.dataManagement.manualBadge': 'Manual action', + 'usage.dataManagement.notice': 'A database backup is created first. Only identifiable Claude / Anthropic records are processed.', + 'usage.dataManagement.actionTitle': 'Repair cache records', + 'usage.dataManagement.actionDescription': 'Use input token accounting that includes cache reads and cache creation.', + 'usage.dataManagement.run': 'Scan and repair', + 'usage.dataManagement.running': 'Processing…', + 'usage.dataManagement.confirm': 'Scan and repair historical cache records? A database backup will be created first.', + 'usage.dataManagement.scanned': 'Scanned', + 'usage.dataManagement.repaired': 'Repaired', + 'usage.dataManagement.backup': 'Backup file', 'usage.trend.title': 'Request and Token Trend', 'usage.trend.description': 'Grouped by local system hour', 'usage.token.title': 'Token Breakdown', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index bdcf29b..63d2eb8 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -111,6 +111,7 @@ export const zhCN = { 'usage.tab.analysis': '分析', 'usage.tab.events': '请求明细', 'usage.tab.pricing': '价格统计', + 'usage.tab.dataManagement': '数据管理', 'usage.refresh': '刷新数据', 'usage.filter.reset': '重置筛选', 'usage.filter.timeRange': '时间范围', @@ -177,6 +178,18 @@ export const zhCN = { 'usage.pricing.saved': '模型价格已保存,总览成本已更新', 'usage.pricing.deleted': '模型价格已删除,总览成本已更新', 'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?', + 'usage.dataManagement.title': '历史数据管理', + 'usage.dataManagement.description': '扫描并修复缓存 Token 口径异常的历史使用记录。', + 'usage.dataManagement.manualBadge': '手动操作', + 'usage.dataManagement.notice': '修复前会自动备份数据库,仅处理可确认的 Claude / Anthropic 异常记录。', + 'usage.dataManagement.actionTitle': '修复异常缓存记录', + 'usage.dataManagement.actionDescription': '将输入 Token 统一为包含缓存读取和缓存创建的统计口径。', + 'usage.dataManagement.run': '扫描并修复', + 'usage.dataManagement.running': '处理中…', + 'usage.dataManagement.confirm': '确定扫描并修复历史缓存记录吗?修复前会自动创建数据库备份。', + 'usage.dataManagement.scanned': '扫描记录', + 'usage.dataManagement.repaired': '修复记录', + 'usage.dataManagement.backup': '备份文件', 'usage.trend.title': '请求与 Token 趋势', 'usage.trend.description': '按系统本地小时聚合', 'usage.token.title': 'Token 构成', diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index b332b5e..58dd1ab 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -22,6 +22,7 @@ import { Terminal, Trash2, TriangleAlert, + Wrench, X, } from 'lucide-react'; import { getCurrentLocale, useI18n } from '../i18n'; @@ -29,7 +30,7 @@ import type { MessageKey } from '../i18n/resources'; import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics'; import { formatUsageNumber } from '../services/usageNumber'; -type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing'; +type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing' | 'data-management'; type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom'; type CollectorStatus = { @@ -158,6 +159,12 @@ type UsagePricing = { savedPrices: number; }; +type UsageRepairResult = { + scanned: number; + repaired: number; + backupPath: string | null; +}; + type ModelPriceSyncResult = { imported: number; skipped: number; @@ -185,7 +192,9 @@ const emptyAnalysis: UsageAnalysis = { models: [], providers: [], sources: [], a const loadTab = (): UsageTab => { try { const saved = localStorage.getItem(TAB_KEY); - return saved === 'analysis' || saved === 'events' || saved === 'pricing' ? saved : 'overview'; + return saved === 'analysis' || saved === 'events' || saved === 'pricing' || saved === 'data-management' + ? saved + : 'overview'; } catch { return 'overview'; } @@ -356,7 +365,7 @@ export function UsageRecordsPage() { setStatus(nextStatus); setOptionsAnalysis(nextOptions); setEvents(nextEvents); - } else { + } else if (activeTab === 'pricing') { const [nextStatus, nextOptions, nextPricing] = await Promise.all([ statusRequest, optionsRequest, @@ -366,6 +375,11 @@ export function UsageRecordsPage() { setStatus(nextStatus); setOptionsAnalysis(nextOptions); setPricing(nextPricing); + } else { + const [nextStatus, nextOptions] = await Promise.all([statusRequest, optionsRequest]); + if (requestId !== requestIdRef.current) return; + setStatus(nextStatus); + setOptionsAnalysis(nextOptions); } setError(''); } catch (requestError) { @@ -475,6 +489,14 @@ export function UsageRecordsPage() { {t('usage.tab.pricing')} +
@@ -677,6 +699,69 @@ export function UsageRecordsPage() { {activeTab === 'pricing' && pricing ? ( loadData(true)} /> ) : null} + {activeTab === 'data-management' ? : null} + + ); +} + +function UsageDataManagementView() { + const { t } = useI18n(); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(''); + + const repair = async () => { + if (!window.confirm(t('usage.dataManagement.confirm'))) return; + setRunning(true); + setError(''); + setResult(null); + try { + const next = await invoke('repair_usage_cache_records'); + setResult(next); + } catch (requestError) { + setError(String(requestError)); + } finally { + setRunning(false); + } + }; + + return ( +
+
+
+
+ {t('usage.dataManagement.manualBadge')} +
+ +
+
+ +
+
+ {t('usage.dataManagement.actionTitle')} + {t('usage.dataManagement.actionDescription')} +
+ +
+ + {error ?
{error}
: null} + {result ? ( +
+
{t('usage.dataManagement.scanned')}{result.scanned.toLocaleString()}
+
{t('usage.dataManagement.repaired')}{result.repaired.toLocaleString()}
+
{t('usage.dataManagement.backup')}{result.backupPath ?? '—'}
+
+ ) : null}
); } diff --git a/src/styles.css b/src/styles.css index 9ed8271..5551938 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7718,6 +7718,95 @@ body.table-col-resizing * { background: var(--theme-fffdf8); } +.usage-data-management-panel { + padding: 24px; +} + +.usage-data-management-heading, +.usage-data-management-action, +.usage-data-management-result { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +.usage-data-management-heading > div { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.usage-data-management-heading h2 { + margin: 0; + font-size: 18px; +} + +.usage-data-management-heading p, +.usage-data-management-action span { + display: block; + margin: 6px 0 0; + color: var(--theme-716b61); + font-size: 13px; +} + +.usage-data-management-badge { + padding: 5px 9px; + border: 1px solid var(--theme-d8d4cc); + border-radius: 999px; + color: var(--theme-716b61); + font-size: 12px; +} + +.usage-data-management-notice { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 22px 0; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--theme-c77b30) 28%, var(--theme-d8d4cc)); + border-radius: 10px; + background: color-mix(in srgb, var(--theme-c77b30) 8%, transparent); + color: var(--theme-716b61); + font-size: 13px; +} + +.usage-data-management-action { + padding: 16px; + border: 1px solid var(--theme-d8d4cc); + border-radius: 12px; + background: var(--theme-f8f5ee); +} + +.usage-data-management-result { + align-items: stretch; + margin-top: 18px; +} + +.usage-data-management-result > div { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 5px; + padding: 12px; + border: 1px solid var(--theme-d8d4cc); + border-radius: 10px; +} + +.usage-data-management-result span { + color: var(--theme-716b61); + font-size: 12px; +} + +.usage-data-management-result strong { + overflow: hidden; + color: var(--theme-2d2a26); + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + .usage-pricing-toolbar { display: flex; align-items: center; @@ -9010,6 +9099,17 @@ body.table-col-resizing * { } @media (max-width: 520px) { + .usage-data-management-heading, + .usage-data-management-action, + .usage-data-management-result { + align-items: stretch; + flex-direction: column; + } + + .usage-data-management-action .primary-button { + width: 100%; + } + .panel-heading { flex-direction: column; }