From bd57545c7f46808ba84b09d6bb11d867ac6a8d85 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 18 Sep 2026 17:00:05 +0800 Subject: [PATCH] feat(usage): add confirmed database cleanup controls --- src-tauri/src/main.rs | 1 + src-tauri/src/usage.rs | 72 ++++++++++++++++++++++++++++ src/i18n/ja.ts | 11 ++++- src/i18n/locales/en.ts | 11 ++++- src/i18n/locales/zh-CN.ts | 11 ++++- src/pages/UsageRecordsPage.tsx | 86 +++++++++++++++++++++++++++++----- src/styles.css | 4 +- 7 files changed, 181 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e841707..1b327f7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2651,6 +2651,7 @@ fn main() { usage::get_usage_storage_settings, usage::repair_usage_cache_records, usage::save_usage_storage_settings, + usage::shrink_usage_database, 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 6fc2125..f7b0f12 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -647,6 +647,26 @@ pub(crate) async fn save_usage_storage_settings( Ok(settings) } +#[tauri::command] +pub(crate) async fn shrink_usage_database( + app: tauri::AppHandle, + target_database_size_mb: u64, +) -> Result { + let settings = run_usage_task(move || { + let root = usage_root_dir()?; + let connection = open_usage_database_at(&root)?; + let deleted = shrink_usage_database_to_mb(&connection, target_database_size_mb)?; + load_usage_storage_settings(&connection, &root, deleted) + }) + .await?; + app.state::() + .set_total_records(settings.total_records); + if settings.deleted_records > 0 { + let _ = app.emit(USAGE_UPDATED_EVENT, Local::now().to_rfc3339()); + } + Ok(settings) +} + fn repair_usage_cache_records_at(root: &Path) -> Result { let mut connection = open_usage_database_at(root)?; let candidate_count: i64 = connection @@ -1520,7 +1540,23 @@ fn enforce_usage_database_limit(connection: &Connection) -> Result let max_bytes = max_database_size_mb .checked_mul(BYTES_PER_MB) .ok_or_else(|| "使用记录数据库大小限制过大".to_string())?; + prune_usage_database_to_bytes(connection, max_bytes) +} +fn shrink_usage_database_to_mb( + connection: &Connection, + target_database_size_mb: u64, +) -> Result { + if target_database_size_mb == 0 { + return Err("数据库瘦身目标必须大于 0 MB".to_string()); + } + let target_bytes = target_database_size_mb + .checked_mul(BYTES_PER_MB) + .ok_or_else(|| "数据库瘦身目标过大".to_string())?; + prune_usage_database_to_bytes(connection, target_bytes) +} + +fn prune_usage_database_to_bytes(connection: &Connection, max_bytes: u64) -> Result { if usage_database_connection_disk_bytes(connection)? <= max_bytes { return Ok(0); } @@ -4389,6 +4425,7 @@ mod tests { let connection = open_test_database(&root); assert_eq!(load_usage_database_limit(&connection).unwrap(), 0); + assert!(shrink_usage_database_to_mb(&connection, 0).is_err()); save_usage_database_limit(&connection, 128).unwrap(); drop(connection); @@ -4398,6 +4435,41 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn one_time_database_shrink_keeps_the_automatic_limit() { + let root = test_root("one-time-database-shrink"); + let mut connection = open_test_database(&root); + save_usage_database_limit(&connection, 128).unwrap(); + + let payload = "x".repeat(8 * 1024); + let transaction = connection.transaction().unwrap(); + for timestamp_ms in 0_i64..320 { + transaction + .execute( + r#"INSERT INTO usage_events ( + event_key, timestamp, timestamp_ms, local_hour, + failure_body, created_at + ) VALUES (?1, ?2, ?3, '2026-01-01T00', ?4, ?2)"#, + params![ + format!("one-time-shrink-{timestamp_ms}"), + format!("2026-01-01T00:00:{timestamp_ms:03}Z"), + timestamp_ms, + payload, + ], + ) + .unwrap(); + } + transaction.commit().unwrap(); + + let deleted = shrink_usage_database_to_mb(&connection, 1).unwrap(); + + assert!(deleted > 0); + assert_eq!(load_usage_database_limit(&connection).unwrap(), 128); + assert!(usage_database_disk_bytes(&root).unwrap() <= BYTES_PER_MB); + drop(connection); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn usage_database_limit_removes_oldest_records_after_collection() { let root = test_root("database-limit-retention"); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 34dfee5..1c85a21 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -448,7 +448,6 @@ export const jaOverrides = { 'usage.pricing.deleted': 'モデル料金を削除し、概要のコストを更新しました', 'usage.pricing.deleteConfirm': 'モデル {model} の料金を削除しますか?', 'usage.dataManagement.title': '履歴データ管理', - 'usage.dataManagement.description': '使用履歴の保存容量を管理し、既知の履歴データ異常を修復します。', 'usage.dataManagement.manualBadge': '手動操作', 'usage.dataManagement.storageTitle': 'データベース容量上限', 'usage.dataManagement.storageDescription': '0 にすると無制限です。上限に達すると古い使用履歴から自動的に削除します。', @@ -460,6 +459,16 @@ export const jaOverrides = { 'usage.dataManagement.storageInvalid': 'データベース容量上限には 0 または正の整数を入力してください。', 'usage.dataManagement.storageSaved': 'データベース容量上限を保存しました。', 'usage.dataManagement.storageSavedWithCleanup': 'データベース容量上限を保存し、古い記録を {deleted} 件削除しました。', + 'usage.dataManagement.shrinkTitle': 'データベースをワンクリックで縮小', + 'usage.dataManagement.shrinkDescription': 'データベースを目標サイズに調整します。確認後、目標サイズ以下になるまで古い使用履歴から削除します。', + 'usage.dataManagement.shrinkInput': '縮小後の目標サイズ(MB)', + 'usage.dataManagement.shrinkRun': '今すぐ縮小', + 'usage.dataManagement.shrinking': '縮小中…', + 'usage.dataManagement.shrinkInvalid': '縮小後の目標サイズには 0 より大きい整数を入力してください。', + 'usage.dataManagement.shrinkConfirmTitle': 'データベース縮小の確認', + 'usage.dataManagement.shrinkConfirm': 'データベースを {size} MB 以下に縮小しますか?古い使用履歴から削除され、この操作は元に戻せません。', + 'usage.dataManagement.shrinkSuccess': '縮小が完了しました。古い記録を {deleted} 件削除し、現在の使用量は {size} です。', + 'usage.dataManagement.shrinkNoCleanup': 'データベースを整理しました。現在の使用量は {size} のため、使用履歴は削除されませんでした。', 'usage.dataManagement.actionTitle': 'キャッシュ記録を修復', 'usage.dataManagement.actionDescription': 'キャッシュ読み取りと作成を含む入力 Token の集計方式に統一します。', 'usage.dataManagement.run': 'スキャンして修復', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ef39dd0..9d26806 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -435,7 +435,6 @@ export const en: Record = { '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': 'Manage usage record storage and repair known historical data anomalies.', 'usage.dataManagement.manualBadge': 'Manual action', 'usage.dataManagement.storageTitle': 'Database size limit', 'usage.dataManagement.storageDescription': 'Set to 0 for no limit. When the limit is reached, the oldest usage records are deleted automatically.', @@ -447,6 +446,16 @@ export const en: Record = { 'usage.dataManagement.storageInvalid': 'Enter 0 or a positive integer for the database size limit.', 'usage.dataManagement.storageSaved': 'Database size limit saved.', 'usage.dataManagement.storageSavedWithCleanup': 'Database size limit saved and {deleted} oldest records deleted.', + 'usage.dataManagement.shrinkTitle': 'One-click database cleanup', + 'usage.dataManagement.shrinkDescription': 'Set the database to the target size. After confirmation, the oldest usage records are deleted until the database is at or below that size.', + 'usage.dataManagement.shrinkInput': 'Cleanup target size (MB)', + 'usage.dataManagement.shrinkRun': 'Clean up now', + 'usage.dataManagement.shrinking': 'Cleaning up…', + 'usage.dataManagement.shrinkInvalid': 'Enter a positive integer for the cleanup target size.', + 'usage.dataManagement.shrinkConfirmTitle': 'Confirm database cleanup', + 'usage.dataManagement.shrinkConfirm': 'Reduce the database to {size} MB or less? The oldest usage records will be deleted and this action cannot be undone.', + 'usage.dataManagement.shrinkSuccess': 'Cleanup complete. Deleted {deleted} oldest records; the database now uses {size}.', + 'usage.dataManagement.shrinkNoCleanup': 'Database cleanup complete. It currently uses {size}, so no usage records were deleted.', 'usage.dataManagement.actionTitle': 'Repair historical anomalies', 'usage.dataManagement.actionDescription': 'Correct Claude cache token accounting and delete records recorded as unknown usage.', 'usage.dataManagement.run': 'Scan and repair', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 571207a..40ccdf8 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -434,7 +434,6 @@ export const zhCN = { 'usage.pricing.deleted': '模型价格已删除,总览成本已更新', 'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?', 'usage.dataManagement.title': '历史数据管理', - 'usage.dataManagement.description': '管理使用记录存储并修复历史数据中的已知异常。', 'usage.dataManagement.manualBadge': '手动操作', 'usage.dataManagement.storageTitle': '数据库大小限制', 'usage.dataManagement.storageDescription': '设为 0 时不限制;达到上限后自动删除最旧的使用记录。', @@ -446,6 +445,16 @@ export const zhCN = { 'usage.dataManagement.storageInvalid': '请输入 0 或正整数作为数据库大小上限。', 'usage.dataManagement.storageSaved': '数据库大小限制已保存。', 'usage.dataManagement.storageSavedWithCleanup': '数据库大小限制已保存,并删除了最旧的 {deleted} 条记录。', + 'usage.dataManagement.shrinkTitle': '数据库一键瘦身', + 'usage.dataManagement.shrinkDescription': '将数据库大小调整到目标大小,确认后将从最旧的使用记录开始删除,直到数据库小于或等于目标大小。', + 'usage.dataManagement.shrinkInput': '瘦身目标大小(MB)', + 'usage.dataManagement.shrinkRun': '一键瘦身', + 'usage.dataManagement.shrinking': '瘦身中…', + 'usage.dataManagement.shrinkInvalid': '请输入大于 0 的整数作为瘦身目标大小。', + 'usage.dataManagement.shrinkConfirmTitle': '确认数据库瘦身', + 'usage.dataManagement.shrinkConfirm': '确定将数据库瘦身到 {size} MB 以内吗?确认后将从最旧的使用记录开始删除,此操作无法撤销。', + 'usage.dataManagement.shrinkSuccess': '瘦身完成,已删除最旧的 {deleted} 条记录,当前占用 {size}。', + 'usage.dataManagement.shrinkNoCleanup': '数据库已整理,当前占用 {size},无需删除使用记录。', 'usage.dataManagement.actionTitle': '修复历史异常记录', 'usage.dataManagement.actionDescription': '修正错误缓存信息,并删除被误记为 unknown 的记录。', 'usage.dataManagement.run': '扫描并修复', diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index 50b2e09..9d95108 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -733,6 +733,8 @@ function UsageDataManagementView() { const [limitDraft, setLimitDraft] = useState('0'); const [loadingLimit, setLoadingLimit] = useState(true); const [savingLimit, setSavingLimit] = useState(false); + const [shrinkDraft, setShrinkDraft] = useState(''); + const [shrinking, setShrinking] = useState(false); const [storageNotice, setStorageNotice] = useState(''); useEffect(() => { @@ -781,6 +783,40 @@ function UsageDataManagementView() { } }; + const shrinkDatabase = async () => { + const normalized = shrinkDraft.trim(); + const targetDatabaseSizeMb = Number(normalized); + if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(targetDatabaseSizeMb) || targetDatabaseSizeMb <= 0) { + setError(t('usage.dataManagement.shrinkInvalid')); + return; + } + const confirmed = await askConfirmation({ + title: t('usage.dataManagement.shrinkConfirmTitle'), + message: t('usage.dataManagement.shrinkConfirm', { size: targetDatabaseSizeMb }), + }); + if (!confirmed) return; + setShrinking(true); + setError(''); + setStorageNotice(''); + try { + const next = await invoke('shrink_usage_database', { targetDatabaseSizeMb }); + setStorage(next); + setStorageNotice(t( + next.deletedRecords > 0 + ? 'usage.dataManagement.shrinkSuccess' + : 'usage.dataManagement.shrinkNoCleanup', + { + deleted: next.deletedRecords.toLocaleString(), + size: formatStorageBytes(next.databaseSizeBytes), + }, + )); + } catch (requestError) { + setError(String(requestError)); + } finally { + setShrinking(false); + } + }; + const repair = async () => { if (!await askConfirmation({ title: t('usage.dataManagement.title'), message: t('usage.dataManagement.confirm') })) return; setRunning(true); @@ -804,10 +840,7 @@ function UsageDataManagementView() {
{t('usage.dataManagement.manualBadge')}
@@ -828,15 +861,14 @@ function UsageDataManagementView() {
+
+
+ {t('usage.dataManagement.shrinkTitle')} + {t('usage.dataManagement.shrinkDescription')} +
+
+ + +
+
+ {storageNotice ? setStorageNotice('')} /> : null}
@@ -860,7 +924,7 @@ function UsageDataManagementView() { {t('usage.dataManagement.actionTitle')} {t('usage.dataManagement.actionDescription')}
- diff --git a/src/styles.css b/src/styles.css index bc1dafc..dd7d40f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -9399,7 +9399,9 @@ body.table-col-resizing * { } .usage-storage-limit-editor input { - width: 104px; + width: 168px; + min-height: 40px; + padding: 0 12px; border: 0; background: transparent; box-shadow: none;