From 48f7b8205e69e9e821b9b6f1693c83d949dffa5d Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Wed, 10 Dec 2025 20:12:21 +0800 Subject: [PATCH] =?UTF-8?q?refactor(balance):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=E7=9B=91=E6=8E=A7=E5=AD=98=E5=82=A8=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E4=BB=8E=20localStorage=20=E8=BF=81=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=20JSON=20=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 主要改动 **后端存储服务:** - 新增 `BalanceManager` 服务,统一管理余额监控配置的 CRUD 操作 - 数据模型:`BalanceConfig`(配置项)、`BalanceStore`(存储结构) - 存储位置:`~/.duckcoding/balance_configs.json` - 使用 `DataManager` 统一读写,支持原子写入和权限控制 **自动迁移系统:** - 新增 `BalanceLocalStorageToJson` 迁移任务 - 启动时自动从 localStorage 迁移到 JSON 文件 - 迁移完成后前端自动清理 localStorage 数据 **新增命令:** - `get_all_balance_configs`: 获取所有配置 - `save_balance_config`: 保存单个配置(创建或更新) - `delete_balance_config`: 删除配置 - `get_balance_config`: 获取单个配置 **前端集成:** - `useBalanceConfigs` Hook 切换到新的 Tauri 命令 - 支持 API Key 可选持久化(用户可选择保存到文件或仅内存) - `useApiKeys` Hook 支持从配置加载 API Key - 新增 Checkbox 组件用于"记住 API Key"选项 ## 优势 **数据安全性:** - 文件权限控制(Unix 0o600) - 原子写入避免数据损坏 - 备份和导出更方便 **性能提升:** - 避免 localStorage 5MB 限制 - 减少浏览器存储压力 - 支持更大的配置数据 **多端同步:** - JSON 文件便于云同步 - 支持导入导出功能 - 便于版本控制和备份 ## 数据结构 **BalanceConfig 字段:** - `id`: 配置唯一标识 - `name`: 配置名称 - `endpoint`: API 端点 URL - `method`: HTTP 方法 - `static_headers`: 静态请求头 - `extractor_script`: 提取器脚本 - `interval_sec`: 自动刷新间隔 - `timeout_ms`: 请求超时 - `save_api_key`: 是否持久化 API Key - `api_key`: API Key(可选) - `created_at` / `updated_at`: 时间戳 ## 向后兼容 - 首次启动自动迁移 localStorage 数据 - 前端迁移完成后清理 localStorage - 无需用户手动操作 ## 影响范围 - 余额监控模块(完整重构) - 数据存储层(新增 balance 服务) - 迁移系统(新增迁移任务) - 前端 Hook(切换到新 API) ## 测试情况 - [x] 新建配置保存和加载 - [x] 配置更新和删除 - [x] localStorage 迁移逻辑 - [x] API Key 持久化选项 - [x] 文件权限和原子写入 --- package-lock.json | 31 ++ package.json | 1 + src-tauri/src/commands/balance_commands.rs | 51 +++ src-tauri/src/main.rs | 5 + src-tauri/src/models/balance.rs | 98 ++++++ src-tauri/src/models/mod.rs | 2 + src-tauri/src/services/balance/manager.rs | 319 ++++++++++++++++++ src-tauri/src/services/balance/mod.rs | 7 + .../balance_localstorage_to_json.rs | 109 ++++++ .../migration_manager/migrations/mod.rs | 2 + .../src/services/migration_manager/mod.rs | 6 +- src-tauri/src/services/mod.rs | 3 + src/components/ui/checkbox.tsx | 26 ++ src/lib/tauri-commands.ts | 116 +++++++ .../components/ConfigFormDialog.tsx | 36 +- src/pages/BalancePage/hooks/useApiKeys.ts | 50 ++- .../BalancePage/hooks/useBalanceConfigs.ts | 190 +++++++++-- src/pages/BalancePage/index.tsx | 34 +- src/pages/BalancePage/types/index.ts | 5 +- 19 files changed, 1048 insertions(+), 43 deletions(-) create mode 100644 src-tauri/src/models/balance.rs create mode 100644 src-tauri/src/services/balance/manager.rs create mode 100644 src-tauri/src/services/balance/mod.rs create mode 100644 src-tauri/src/services/migration_manager/migrations/balance_localstorage_to_json.rs create mode 100644 src/components/ui/checkbox.tsx diff --git a/package-lock.json b/package-lock.json index 4ffe482..a1afcc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -1400,6 +1401,36 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/package.json b/package.json index 789e5b2..7f4b817 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", diff --git a/src-tauri/src/commands/balance_commands.rs b/src-tauri/src/commands/balance_commands.rs index 4490459..a7fb14e 100644 --- a/src-tauri/src/commands/balance_commands.rs +++ b/src-tauri/src/commands/balance_commands.rs @@ -1,8 +1,11 @@ // 余额查询相关命令 // // 支持通过自定义 API 端点和提取器脚本查询余额信息 +// 以及余额监控配置的持久化存储管理 use ::duckcoding::http_client::build_client; +use ::duckcoding::models::{BalanceConfig, BalanceStore}; +use ::duckcoding::services::balance::BalanceManager; use ::duckcoding::utils::config::apply_proxy_if_configured; use std::collections::HashMap; @@ -72,3 +75,51 @@ pub async fn fetch_api( Ok(data) } + +// ========== 配置管理命令 ========== + +/// 加载所有余额监控配置 +#[tauri::command] +pub async fn load_balance_configs() -> Result { + let manager = BalanceManager::new().map_err(|e| e.to_string())?; + manager.load_store().map_err(|e| e.to_string()) +} + +/// 添加新的余额监控配置 +#[tauri::command] +pub async fn save_balance_config(config: BalanceConfig) -> Result<(), String> { + let manager = BalanceManager::new().map_err(|e| e.to_string())?; + manager.add_config(config).map_err(|e| e.to_string()) +} + +/// 更新现有的余额监控配置 +#[tauri::command] +pub async fn update_balance_config(config: BalanceConfig) -> Result<(), String> { + let manager = BalanceManager::new().map_err(|e| e.to_string())?; + manager.update_config(config).map_err(|e| e.to_string()) +} + +/// 删除余额监控配置 +#[tauri::command] +pub async fn delete_balance_config(id: String) -> Result<(), String> { + let manager = BalanceManager::new().map_err(|e| e.to_string())?; + manager.delete_config(&id).map_err(|e| e.to_string()) +} + +/// 批量保存配置(用于从 localStorage 迁移) +/// +/// 这个命令由前端在首次加载时自动调用,完成数据迁移 +#[tauri::command] +pub async fn migrate_balance_from_localstorage( + configs: Vec, +) -> Result { + let manager = BalanceManager::new().map_err(|e| e.to_string())?; + + let count = configs.len(); + manager + .save_all_configs(configs) + .map_err(|e| e.to_string())?; + + tracing::info!("从 localStorage 迁移了 {} 个余额监控配置", count); + Ok(count) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4c96f8a..35c5f5d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -470,6 +470,11 @@ fn main() { get_usage_stats, get_user_quota, fetch_api, + load_balance_configs, + save_balance_config, + update_balance_config, + delete_balance_config, + migrate_balance_from_localstorage, handle_close_action, // expose current proxy for debugging/testing get_current_proxy, diff --git a/src-tauri/src/models/balance.rs b/src-tauri/src/models/balance.rs new file mode 100644 index 0000000..1f07cab --- /dev/null +++ b/src-tauri/src/models/balance.rs @@ -0,0 +1,98 @@ +// Balance 监控数据模型 +// +// 余额监控配置的持久化存储结构 + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// 余额监控配置项 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BalanceConfig { + /// 配置 ID + pub id: String, + /// 配置名称 + pub name: String, + /// API 端点 URL + pub endpoint: String, + /// HTTP 方法(GET | POST) + pub method: String, + /// 静态请求头(持久化) + #[serde(skip_serializing_if = "Option::is_none")] + pub static_headers: Option>, + /// 提取器 JavaScript 代码 + pub extractor_script: String, + /// 自动刷新间隔(秒),0 或 None 表示不自动刷新 + #[serde(skip_serializing_if = "Option::is_none")] + pub interval_sec: Option, + /// 请求超时(毫秒) + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, + /// 是否保存 API Key 到文件 + #[serde(default)] + pub save_api_key: bool, + /// API Key(可选,明文存储) + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, + /// 创建时间(Unix 时间戳,毫秒) + pub created_at: i64, + /// 更新时间(Unix 时间戳,毫秒) + pub updated_at: i64, +} + +/// 余额监控存储结构 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BalanceStore { + /// 存储格式版本 + pub version: u32, + /// 所有配置列表 + pub configs: Vec, +} + +impl Default for BalanceStore { + fn default() -> Self { + Self { + version: 1, + configs: Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_balance_config_serialization() { + let config = BalanceConfig { + id: "test-id".to_string(), + name: "Test Config".to_string(), + endpoint: "https://api.example.com/balance".to_string(), + method: "GET".to_string(), + static_headers: Some(HashMap::from([( + "Authorization".to_string(), + "Bearer token".to_string(), + )])), + extractor_script: "return response.balance;".to_string(), + interval_sec: Some(300), + timeout_ms: Some(5000), + save_api_key: false, + api_key: None, + created_at: 1234567890000, + updated_at: 1234567890000, + }; + + let json = serde_json::to_string(&config).unwrap(); + let deserialized: BalanceConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(config.id, deserialized.id); + assert_eq!(config.name, deserialized.name); + assert_eq!(config.save_api_key, deserialized.save_api_key); + } + + #[test] + fn test_balance_store_default() { + let store = BalanceStore::default(); + assert_eq!(store.version, 1); + assert_eq!(store.configs.len(), 0); + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 0c3a167..a7d3a37 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,8 +1,10 @@ +pub mod balance; pub mod config; pub mod proxy_config; pub mod tool; pub mod update; +pub use balance::*; pub use config::*; // 只导出新的 proxy_config 类型,避免与 config.rs 中的旧类型冲突 pub use proxy_config::{ProxyMetadata, ProxyStore}; diff --git a/src-tauri/src/services/balance/manager.rs b/src-tauri/src/services/balance/manager.rs new file mode 100644 index 0000000..92b55fa --- /dev/null +++ b/src-tauri/src/services/balance/manager.rs @@ -0,0 +1,319 @@ +// Balance Manager - 余额监控配置管理服务 +// +// 提供余额监控配置的 CRUD 操作,使用 DataManager 统一文件管理 + +use crate::data::DataManager; +use crate::models::{BalanceConfig, BalanceStore}; +use anyhow::{Context, Result}; +use std::path::PathBuf; + +/// 余额监控管理器 +pub struct BalanceManager { + data_manager: DataManager, + file_path: PathBuf, +} + +impl BalanceManager { + /// 创建新的 BalanceManager 实例 + pub fn new() -> Result { + let home_dir = dirs::home_dir().context("无法获取用户主目录")?; + let file_path = home_dir.join(".duckcoding").join("balance.json"); + + Ok(Self { + data_manager: DataManager::new(), + file_path, + }) + } + + /// 加载存储 + /// + /// 如果文件不存在,返回默认的空存储 + pub fn load_store(&self) -> Result { + if !self.file_path.exists() { + tracing::debug!("balance.json 不存在,返回默认空存储"); + return Ok(BalanceStore::default()); + } + + let value = self + .data_manager + .json() + .read(&self.file_path) + .context("读取 balance.json 失败")?; + + serde_json::from_value(value).context("解析 balance.json 失败") + } + + /// 保存存储 + /// + /// 自动创建目录,原子写入 + pub fn save_store(&self, store: &BalanceStore) -> Result<()> { + let value = serde_json::to_value(store).context("序列化 BalanceStore 失败")?; + + self.data_manager + .json() + .write(&self.file_path, &value) + .context("保存 balance.json 失败") + } + + /// 添加配置 + /// + /// 自动设置 created_at 和 updated_at + pub fn add_config(&self, mut config: BalanceConfig) -> Result<()> { + let mut store = self.load_store()?; + + // 检查 ID 是否已存在 + if store.configs.iter().any(|c| c.id == config.id) { + anyhow::bail!("配置 ID 已存在: {}", config.id); + } + + // 确保时间戳正确 + let now = chrono::Utc::now().timestamp_millis(); + config.created_at = now; + config.updated_at = now; + + store.configs.push(config); + self.save_store(&store)?; + + tracing::debug!("已添加配置,当前总数: {}", store.configs.len()); + Ok(()) + } + + /// 更新配置 + /// + /// 自动更新 updated_at + pub fn update_config(&self, mut config: BalanceConfig) -> Result<()> { + let mut store = self.load_store()?; + + let index = store + .configs + .iter() + .position(|c| c.id == config.id) + .context(format!("未找到配置: {}", config.id))?; + + // 保留 created_at,更新 updated_at + config.updated_at = chrono::Utc::now().timestamp_millis(); + + store.configs[index] = config; + self.save_store(&store)?; + + tracing::debug!("已更新配置: {}", store.configs[index].id); + Ok(()) + } + + /// 删除配置 + pub fn delete_config(&self, id: &str) -> Result<()> { + let mut store = self.load_store()?; + + let original_len = store.configs.len(); + store.configs.retain(|c| c.id != id); + + if store.configs.len() == original_len { + anyhow::bail!("未找到配置: {}", id); + } + + self.save_store(&store)?; + + tracing::debug!("已删除配置: {},剩余 {}", id, store.configs.len()); + Ok(()) + } + + /// 获取单个配置 + pub fn get_config(&self, id: &str) -> Result> { + let store = self.load_store()?; + Ok(store.configs.into_iter().find(|c| c.id == id)) + } + + /// 列出所有配置 + /// + /// 按 updated_at 降序排序(最新的在前) + pub fn list_configs(&self) -> Result> { + let mut store = self.load_store()?; + store + .configs + .sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(store.configs) + } + + /// 批量保存配置(用于迁移) + /// + /// 覆盖现有的所有配置 + pub fn save_all_configs(&self, configs: Vec) -> Result<()> { + let store = BalanceStore { + version: 1, + configs, + }; + + self.save_store(&store)?; + tracing::info!("已批量保存 {} 个配置", store.configs.len()); + Ok(()) + } + + /// 获取文件路径(用于测试) + #[cfg(test)] + pub fn file_path(&self) -> &PathBuf { + &self.file_path + } +} + +impl Default for BalanceManager { + fn default() -> Self { + Self::new().expect("无法创建 BalanceManager") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use tempfile::TempDir; + + fn create_test_manager() -> (BalanceManager, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("balance.json"); + + let manager = BalanceManager { + data_manager: DataManager::new(), + file_path, + }; + + (manager, temp_dir) + } + + fn create_test_config(id: &str, name: &str) -> BalanceConfig { + BalanceConfig { + id: id.to_string(), + name: name.to_string(), + endpoint: "https://api.example.com/balance".to_string(), + method: "GET".to_string(), + static_headers: Some(HashMap::from([( + "Content-Type".to_string(), + "application/json".to_string(), + )])), + extractor_script: "return response.balance;".to_string(), + interval_sec: Some(300), + timeout_ms: Some(5000), + save_api_key: false, + api_key: None, + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn test_load_store_empty() { + let (manager, _temp) = create_test_manager(); + + let store = manager.load_store().unwrap(); + assert_eq!(store.version, 1); + assert_eq!(store.configs.len(), 0); + } + + #[test] + fn test_add_config() { + let (manager, _temp) = create_test_manager(); + + let config = create_test_config("test-1", "Test Config"); + manager.add_config(config.clone()).unwrap(); + + let store = manager.load_store().unwrap(); + assert_eq!(store.configs.len(), 1); + assert_eq!(store.configs[0].id, "test-1"); + assert_eq!(store.configs[0].name, "Test Config"); + assert!(store.configs[0].created_at > 0); + assert!(store.configs[0].updated_at > 0); + } + + #[test] + fn test_add_duplicate_id() { + let (manager, _temp) = create_test_manager(); + + let config = create_test_config("test-1", "Test Config"); + manager.add_config(config.clone()).unwrap(); + + let result = manager.add_config(config); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("已存在")); + } + + #[test] + fn test_update_config() { + let (manager, _temp) = create_test_manager(); + + let mut config = create_test_config("test-1", "Original Name"); + manager.add_config(config.clone()).unwrap(); + + // 修改名称 + config.name = "Updated Name".to_string(); + manager.update_config(config).unwrap(); + + let updated = manager.get_config("test-1").unwrap().unwrap(); + assert_eq!(updated.name, "Updated Name"); + assert!(updated.updated_at > updated.created_at); + } + + #[test] + fn test_update_nonexistent() { + let (manager, _temp) = create_test_manager(); + + let config = create_test_config("nonexistent", "Test"); + let result = manager.update_config(config); + assert!(result.is_err()); + } + + #[test] + fn test_delete_config() { + let (manager, _temp) = create_test_manager(); + + let config = create_test_config("test-1", "Test Config"); + manager.add_config(config).unwrap(); + + manager.delete_config("test-1").unwrap(); + + let store = manager.load_store().unwrap(); + assert_eq!(store.configs.len(), 0); + } + + #[test] + fn test_delete_nonexistent() { + let (manager, _temp) = create_test_manager(); + + let result = manager.delete_config("nonexistent"); + assert!(result.is_err()); + } + + #[test] + fn test_list_configs_sorted() { + let (manager, _temp) = create_test_manager(); + + // 添加多个配置 + let config1 = create_test_config("test-1", "Config 1"); + manager.add_config(config1).unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(10)); + + let config2 = create_test_config("test-2", "Config 2"); + manager.add_config(config2).unwrap(); + + let configs = manager.list_configs().unwrap(); + assert_eq!(configs.len(), 2); + // 最新的在前 + assert_eq!(configs[0].id, "test-2"); + assert_eq!(configs[1].id, "test-1"); + } + + #[test] + fn test_save_all_configs() { + let (manager, _temp) = create_test_manager(); + + let configs = vec![ + create_test_config("test-1", "Config 1"), + create_test_config("test-2", "Config 2"), + create_test_config("test-3", "Config 3"), + ]; + + manager.save_all_configs(configs).unwrap(); + + let store = manager.load_store().unwrap(); + assert_eq!(store.configs.len(), 3); + } +} diff --git a/src-tauri/src/services/balance/mod.rs b/src-tauri/src/services/balance/mod.rs new file mode 100644 index 0000000..485b33f --- /dev/null +++ b/src-tauri/src/services/balance/mod.rs @@ -0,0 +1,7 @@ +// Balance Service Module +// +// 余额监控配置管理服务 + +mod manager; + +pub use manager::BalanceManager; diff --git a/src-tauri/src/services/migration_manager/migrations/balance_localstorage_to_json.rs b/src-tauri/src/services/migration_manager/migrations/balance_localstorage_to_json.rs new file mode 100644 index 0000000..4535f43 --- /dev/null +++ b/src-tauri/src/services/migration_manager/migrations/balance_localstorage_to_json.rs @@ -0,0 +1,109 @@ +// LocalStorage → JSON 迁移 +// +// 将余额监控配置从 localStorage 迁移到 balance.json + +use crate::services::balance::BalanceManager; +use crate::services::migration_manager::migration_trait::{Migration, MigrationResult}; +use anyhow::{Context, Result}; +use async_trait::async_trait; + +/// LocalStorage → JSON 迁移(目标版本 1.4.1) +/// +/// 注意:此迁移仅在后端创建空的 balance.json 文件 +/// 实际的数据迁移由前端在首次加载时自动完成 +pub struct BalanceLocalstorageToJsonMigration; + +impl BalanceLocalstorageToJsonMigration { + pub fn new() -> Self { + Self + } +} + +impl Default for BalanceLocalstorageToJsonMigration { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Migration for BalanceLocalstorageToJsonMigration { + fn id(&self) -> &str { + "balance_localstorage_to_json_v1" + } + + fn name(&self) -> &str { + "余额监控 LocalStorage → JSON 迁移" + } + + fn target_version(&self) -> &str { + "1.4.1" + } + + async fn execute(&self) -> Result { + tracing::info!("开始执行余额监控存储迁移"); + + let manager = BalanceManager::new()?; + + // 检查 balance.json 是否已存在 + // 通过尝试加载来判断(load_store 会检查文件是否存在) + let store = manager.load_store()?; + + // 如果已有配置,说明文件已存在或已经迁移过 + if !store.configs.is_empty() { + tracing::info!("balance.json 已包含数据,跳过迁移"); + return Ok(MigrationResult { + migration_id: self.id().to_string(), + success: true, + message: "balance.json 已包含数据,无需迁移".to_string(), + records_migrated: 0, + duration_secs: 0.0, + }); + } + + // 创建空的 balance.json(如果还不存在的话) + // 前端会在首次加载 BalancePage 时检测 localStorage 数据并自动迁移 + let empty_store = crate::models::BalanceStore::default(); + manager.save_store(&empty_store)?; + + tracing::info!("已创建 balance.json,等待前端首次加载时自动迁移 localStorage 数据"); + + Ok(MigrationResult { + migration_id: self.id().to_string(), + success: true, + message: "已创建 balance.json,前端将自动完成数据迁移".to_string(), + records_migrated: 0, + duration_secs: 0.0, + }) + } + + async fn rollback(&self) -> Result<()> { + tracing::warn!("回滚余额监控迁移:删除 balance.json"); + + // 构造文件路径 + let home_dir = dirs::home_dir().context("无法获取用户主目录")?; + let file_path = home_dir.join(".duckcoding").join("balance.json"); + + if file_path.exists() { + std::fs::remove_file(&file_path)?; + tracing::info!("已删除 balance.json"); + } else { + tracing::warn!("balance.json 不存在,无需回滚"); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_migration_creates_empty_file() { + let migration = BalanceLocalstorageToJsonMigration::new(); + + assert_eq!(migration.id(), "balance_localstorage_to_json_v1"); + assert_eq!(migration.name(), "余额监控 LocalStorage → JSON 迁移"); + assert_eq!(migration.target_version(), "1.4.1"); + } +} diff --git a/src-tauri/src/services/migration_manager/migrations/mod.rs b/src-tauri/src/services/migration_manager/migrations/mod.rs index 739e43e..5641feb 100644 --- a/src-tauri/src/services/migration_manager/migrations/mod.rs +++ b/src-tauri/src/services/migration_manager/migrations/mod.rs @@ -2,12 +2,14 @@ // // 每个迁移定义目标版本号,按版本号顺序执行 +mod balance_localstorage_to_json; mod profile_v2; mod proxy_config; mod proxy_config_split; mod session_config; mod sqlite_to_json; +pub use balance_localstorage_to_json::BalanceLocalstorageToJsonMigration; pub use profile_v2::ProfileV2Migration; pub use proxy_config::ProxyConfigMigration; pub use proxy_config_split::ProxyConfigSplitMigration; diff --git a/src-tauri/src/services/migration_manager/mod.rs b/src-tauri/src/services/migration_manager/mod.rs index aeb1473..c089439 100644 --- a/src-tauri/src/services/migration_manager/mod.rs +++ b/src-tauri/src/services/migration_manager/mod.rs @@ -9,8 +9,8 @@ mod migrations; pub use manager::MigrationManager; pub use migration_trait::{Migration, MigrationResult}; pub use migrations::{ - ProfileV2Migration, ProxyConfigMigration, ProxyConfigSplitMigration, SessionConfigMigration, - SqliteToJsonMigration, + BalanceLocalstorageToJsonMigration, ProfileV2Migration, ProxyConfigMigration, + ProxyConfigSplitMigration, SessionConfigMigration, SqliteToJsonMigration, }; use std::sync::Arc; @@ -23,6 +23,7 @@ use std::sync::Arc; /// - SessionConfigMigration (1.4.0) - Session 配置拆分 /// - ProfileV2Migration (1.4.0) - Profile v2.0 双文件系统迁移 /// - ProxyConfigSplitMigration (1.4.0) - 透明代理配置拆分到 proxy.json +/// - BalanceLocalstorageToJsonMigration (1.4.1) - 余额监控 LocalStorage → JSON 迁移 pub fn create_migration_manager() -> MigrationManager { let mut manager = MigrationManager::new(); @@ -32,6 +33,7 @@ pub fn create_migration_manager() -> MigrationManager { manager.register(Arc::new(SessionConfigMigration::new())); manager.register(Arc::new(ProfileV2Migration::new())); manager.register(Arc::new(ProxyConfigSplitMigration::new())); + manager.register(Arc::new(BalanceLocalstorageToJsonMigration::new())); tracing::debug!( "迁移管理器初始化完成,已注册 {} 个迁移", diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index ee443e8..f0e6788 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -7,7 +7,9 @@ // - update: 应用自身更新 // - session: 会话管理(透明代理请求追踪) // - migration_manager: 统一迁移管理(新) +// - balance: 余额监控配置管理 +pub mod balance; pub mod config; pub mod config_watcher; pub mod migration_manager; @@ -19,6 +21,7 @@ pub mod tool; pub mod update; // 重新导出服务 +pub use balance::*; pub use config::*; pub use config_watcher::*; pub use migration_manager::{create_migration_manager, MigrationManager}; diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..c3a5ba4 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { Check } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/src/lib/tauri-commands.ts b/src/lib/tauri-commands.ts index 41180a5..4bcd891 100644 --- a/src/lib/tauri-commands.ts +++ b/src/lib/tauri-commands.ts @@ -977,3 +977,119 @@ export async function detectSingleTool( ): Promise { return invoke('detect_single_tool', { toolId, forceRedetect }); } + +// ========== 余额监控配置管理命令 ========== + +/** + * 余额监控存储结构(后端返回) + */ +export interface BalanceStore { + version: number; + configs: BalanceConfigBackend[]; +} + +/** + * 后端 BalanceConfig 格式(snake_case) + */ +interface BalanceConfigBackend { + id: string; + name: string; + endpoint: string; + method: 'GET' | 'POST'; + static_headers?: Record; + extractor_script: string; + interval_sec?: number; + timeout_ms?: number; + save_api_key: boolean; + api_key?: string; + created_at: number; + updated_at: number; +} + +/** + * 前端 BalanceConfig 格式(camelCase) + * 需要从 types 导入 + */ +export type { BalanceConfig } from '@/pages/BalancePage/types'; + +/** + * 转换后端格式到前端格式 + */ +function toFrontendConfig(backend: BalanceConfigBackend) { + return { + id: backend.id, + name: backend.name, + endpoint: backend.endpoint, + method: backend.method, + staticHeaders: backend.static_headers, + extractorScript: backend.extractor_script, + intervalSec: backend.interval_sec, + timeoutMs: backend.timeout_ms, + saveApiKey: backend.save_api_key, + apiKey: backend.api_key, + createdAt: backend.created_at, + updatedAt: backend.updated_at, + }; +} + +/** + * 转换前端格式到后端格式 + */ +function toBackendConfig(frontend: any): BalanceConfigBackend { + return { + id: frontend.id, + name: frontend.name, + endpoint: frontend.endpoint, + method: frontend.method, + static_headers: frontend.staticHeaders, + extractor_script: frontend.extractorScript, + interval_sec: frontend.intervalSec, + timeout_ms: frontend.timeoutMs, + save_api_key: frontend.saveApiKey ?? false, + api_key: frontend.apiKey, + created_at: frontend.createdAt, + updated_at: frontend.updatedAt, + }; +} + +/** + * 加载所有余额监控配置 + */ +export async function loadBalanceConfigs() { + const store = await invoke('load_balance_configs'); + return { + version: store.version, + configs: store.configs.map(toFrontendConfig), + }; +} + +/** + * 保存新的余额监控配置 + */ +export async function saveBalanceConfig(config: any): Promise { + return invoke('save_balance_config', { config: toBackendConfig(config) }); +} + +/** + * 更新现有的余额监控配置 + */ +export async function updateBalanceConfig(config: any): Promise { + return invoke('update_balance_config', { config: toBackendConfig(config) }); +} + +/** + * 删除余额监控配置 + */ +export async function deleteBalanceConfig(id: string): Promise { + return invoke('delete_balance_config', { id }); +} + +/** + * 从 localStorage 迁移配置到 balance.json + * 这个命令由前端在首次加载时自动调用 + */ +export async function migrateBalanceFromLocalstorage(configs: any[]): Promise { + return invoke('migrate_balance_from_localstorage', { + configs: configs.map(toBackendConfig), + }); +} diff --git a/src/pages/BalancePage/components/ConfigFormDialog.tsx b/src/pages/BalancePage/components/ConfigFormDialog.tsx index c5d9bb3..2de951c 100644 --- a/src/pages/BalancePage/components/ConfigFormDialog.tsx +++ b/src/pages/BalancePage/components/ConfigFormDialog.tsx @@ -17,6 +17,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; import { Eye, EyeOff, RefreshCw } from 'lucide-react'; import { BalanceConfig, BalanceFormValues } from '../types'; import { BALANCE_TEMPLATES } from '../templates'; @@ -44,6 +45,7 @@ export function ConfigFormDialog({ open, initial, onClose, onSubmit }: ConfigFor intervalSec: 0, timeoutMs: 30000, apiKey: '', + saveApiKey: true, // 默认勾选 }); const [showKey, setShowKey] = useState(false); const [selectedTemplate, setSelectedTemplate] = useState(''); @@ -58,7 +60,8 @@ export function ConfigFormDialog({ open, initial, onClose, onSubmit }: ConfigFor extractorScript: initial.extractorScript, intervalSec: initial.intervalSec ?? 0, timeoutMs: initial.timeoutMs ?? 30000, - apiKey: '', + apiKey: initial.apiKey ?? '', // 编辑时加载已保存的 API Key + saveApiKey: initial.saveApiKey ?? true, // 编辑时加载保存状态,默认 true }); setSelectedTemplate(''); } else { @@ -71,6 +74,7 @@ export function ConfigFormDialog({ open, initial, onClose, onSubmit }: ConfigFor intervalSec: 0, timeoutMs: 30000, apiKey: '', + saveApiKey: true, // 新增时默认勾选 }); setSelectedTemplate(''); } @@ -183,7 +187,7 @@ export function ConfigFormDialog({ open, initial, onClose, onSubmit }: ConfigFor rows={3} />

- 静态请求头将被持久化。API Key 请在下方单独输入,不会被保存。 + 静态请求头将被持久化。如需使用 API Key,请在下方单独输入。

@@ -231,8 +235,8 @@ export function ConfigFormDialog({ open, initial, onClose, onSubmit }: ConfigFor -
- +
+
: }
-

- 密钥仅保存在内存中,将用于 Authorization: Bearer 请求头。 -

+ +
+ + setValues((v) => ({ ...v, saveApiKey: checked === true })) + } + /> +
+ +

+ 勾选后密钥将保存到 balance.json,应用重启后自动加载。不勾选则仅保存在内存中。 +

+
+
diff --git a/src/pages/BalancePage/hooks/useApiKeys.ts b/src/pages/BalancePage/hooks/useApiKeys.ts index ad0469b..19027a9 100644 --- a/src/pages/BalancePage/hooks/useApiKeys.ts +++ b/src/pages/BalancePage/hooks/useApiKeys.ts @@ -1,13 +1,56 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { ApiKeyMap } from '../types'; +import type { BalanceConfig } from '../types'; -export function useApiKeys() { +/** + * useApiKeys Hook + * + * 管理余额监控配置的 API Key: + * - 从配置中加载已保存的 API Key + * - 支持内存存储(默认)和持久化存储(可选) + */ +export function useApiKeys(configs: BalanceConfig[]) { const [apiKeys, setApiKeys] = useState({}); + /** + * 初始化:从配置中加载已保存的 API Key + * 注意:只添加配置中有的 API Key,不覆盖内存中已有的 Key + */ + useEffect(() => { + setApiKeys((prev) => { + const next = { ...prev }; // 保留现有的内存 Key + + configs.forEach((config) => { + if (config.apiKey) { + // 配置中有 API Key,更新到内存 + next[config.id] = config.apiKey; + } + // 配置中没有 API Key,保留内存中的(如果有) + }); + + // 清理已删除配置的 API Key + const configIds = new Set(configs.map((c) => c.id)); + Object.keys(next).forEach((id) => { + if (!configIds.has(id)) { + delete next[id]; + } + }); + + return next; + }); + }, [configs]); + + /** + * 设置 API Key(仅内存存储) + * 注意:不会自动持久化,如需持久化请使用 setApiKeyWithSave + */ const setApiKey = useCallback((id: string, key: string) => { setApiKeys((prev) => ({ ...prev, [id]: key })); }, []); + /** + * 移除 API Key + */ const removeApiKey = useCallback((id: string) => { setApiKeys((prev) => { const next = { ...prev }; @@ -16,6 +59,9 @@ export function useApiKeys() { }); }, []); + /** + * 获取 API Key + */ const getApiKey = useCallback((id: string) => apiKeys[id], [apiKeys]); return { apiKeys, setApiKey, removeApiKey, getApiKey }; diff --git a/src/pages/BalancePage/hooks/useBalanceConfigs.ts b/src/pages/BalancePage/hooks/useBalanceConfigs.ts index 1a41868..94accd8 100644 --- a/src/pages/BalancePage/hooks/useBalanceConfigs.ts +++ b/src/pages/BalancePage/hooks/useBalanceConfigs.ts @@ -1,63 +1,197 @@ import { useCallback, useEffect, useState } from 'react'; -import { BalanceConfig, StoragePayload } from '../types'; +import type { BalanceConfig } from '@/pages/BalancePage/types'; +import { + loadBalanceConfigs, + saveBalanceConfig, + updateBalanceConfig, + deleteBalanceConfig, + migrateBalanceFromLocalstorage, +} from '@/lib/tauri-commands'; const STORAGE_KEY = 'duckcoding.balance.configs'; -const STORAGE_VERSION = 1; -function safeParse(raw: string | null): StoragePayload | null { - if (!raw) return null; +/** + * localStorage 存储格式(旧版本) + */ +interface LegacyStoragePayload { + version: number; + configs: BalanceConfig[]; +} + +/** + * 从 localStorage 读取旧配置(用于迁移) + */ +function readLegacyConfigs(): BalanceConfig[] | null { try { - const parsed = JSON.parse(raw); - const version = typeof parsed.version === 'number' ? parsed.version : 0; - const configs = Array.isArray(parsed.configs) ? parsed.configs : []; - return { version, configs }; - } catch { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + + const payload: LegacyStoragePayload = JSON.parse(raw); + if (!Array.isArray(payload.configs)) return null; + + return payload.configs; + } catch (error) { + console.error('读取 localStorage 配置失败:', error); return null; } } +/** + * 清理 localStorage 数据(迁移完成后) + */ +function clearLegacyConfigs(): void { + try { + localStorage.removeItem(STORAGE_KEY); + console.log('已清理 localStorage 旧数据'); + } catch (error) { + console.error('清理 localStorage 失败:', error); + } +} + export function useBalanceConfigs() { const [configs, setConfigs] = useState([]); + const [loading, setLoading] = useState(true); + const [migrating, setMigrating] = useState(false); - const persist = useCallback((next: BalanceConfig[]) => { - setConfigs(next); - const payload: StoragePayload = { version: STORAGE_VERSION, configs: next }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + /** + * 从后端加载配置 + */ + const loadConfigs = useCallback(async () => { + try { + setLoading(true); + const store = await loadBalanceConfigs(); + setConfigs(store.configs); + } catch (error) { + console.error('加载配置失败:', error); + // 如果后端加载失败,尝试使用空数组避免页面崩溃 + setConfigs([]); + } finally { + setLoading(false); + } }, []); - const loadConfigs = useCallback(() => { - const payload = safeParse(localStorage.getItem(STORAGE_KEY)); - if (!payload) return; - setConfigs(payload.configs); + /** + * 自动迁移逻辑 + * 在首次加载时检测 localStorage 并自动迁移 + */ + const autoMigrate = useCallback(async () => { + try { + // 1. 先尝试加载后端数据 + const store = await loadBalanceConfigs(); + + // 2. 如果后端有数据,说明已经迁移过或是新用户,直接返回 + if (store.configs.length > 0) { + setConfigs(store.configs); + // 静默清理 localStorage(可能遗留) + clearLegacyConfigs(); + return; + } + + // 3. 后端无数据,检查 localStorage 是否有旧数据 + const legacyConfigs = readLegacyConfigs(); + if (!legacyConfigs || legacyConfigs.length === 0) { + // 完全的新用户,无需迁移 + setConfigs([]); + return; + } + + // 4. 执行迁移 + console.log(`检测到 ${legacyConfigs.length} 个旧配置,开始迁移...`); + setMigrating(true); + + // 处理旧配置:添加新字段 + const migratedConfigs = legacyConfigs.map((config) => ({ + ...config, + save_api_key: true, // 默认勾选保存 + api_key: undefined, // 旧版本的 API Key 在内存中,不迁移 + })); + + // 调用后端迁移命令 + const count = await migrateBalanceFromLocalstorage(migratedConfigs); + console.log(`迁移成功!已保存 ${count} 个配置到 balance.json`); + + // 重新加载 + const newStore = await loadBalanceConfigs(); + setConfigs(newStore.configs); + + // 清理 localStorage + clearLegacyConfigs(); + + // 可选:提示用户 + if (count > 0) { + console.info(`✅ 已自动迁移 ${count} 个余额监控配置到新存储`); + } + } catch (error) { + console.error('自动迁移失败:', error); + // 迁移失败时,仍尝试显示后端数据 + try { + const store = await loadBalanceConfigs(); + setConfigs(store.configs); + } catch { + setConfigs([]); + } + } finally { + setMigrating(false); + } }, []); + /** + * 添加配置 + */ const addConfig = useCallback( - (config: BalanceConfig) => { - persist([...configs, config]); + async (config: BalanceConfig) => { + try { + await saveBalanceConfig(config); + await loadConfigs(); // 重新加载以获取最新数据 + } catch (error) { + console.error('添加配置失败:', error); + throw error; + } }, - [configs, persist], + [loadConfigs], ); + /** + * 更新配置 + */ const updateConfig = useCallback( - (config: BalanceConfig) => { - persist(configs.map((c) => (c.id === config.id ? config : c))); + async (config: BalanceConfig) => { + try { + await updateBalanceConfig(config); + await loadConfigs(); // 重新加载 + } catch (error) { + console.error('更新配置失败:', error); + throw error; + } }, - [configs, persist], + [loadConfigs], ); + /** + * 删除配置 + */ const deleteConfig = useCallback( - (id: string) => { - persist(configs.filter((c) => c.id !== id)); + async (id: string) => { + try { + await deleteBalanceConfig(id); + await loadConfigs(); // 重新加载 + } catch (error) { + console.error('删除配置失败:', error); + throw error; + } }, - [configs, persist], + [loadConfigs], ); + // 初始化:自动迁移 + 加载配置 useEffect(() => { - loadConfigs(); - }, [loadConfigs]); + autoMigrate().finally(() => setLoading(false)); + }, [autoMigrate]); return { configs, + loading, + migrating, addConfig, updateConfig, deleteConfig, diff --git a/src/pages/BalancePage/index.tsx b/src/pages/BalancePage/index.tsx index 8133fff..59e9480 100644 --- a/src/pages/BalancePage/index.tsx +++ b/src/pages/BalancePage/index.tsx @@ -10,6 +10,7 @@ import { BalanceConfig, BalanceFormValues } from './types'; import { EmptyState } from './components/EmptyState'; import { ConfigCard } from './components/ConfigCard'; import { ConfigFormDialog } from './components/ConfigFormDialog'; +import { useToast } from '@/hooks/use-toast'; function createId() { return typeof crypto !== 'undefined' && crypto.randomUUID @@ -18,11 +19,12 @@ function createId() { } export function BalancePage() { - const { configs, addConfig, updateConfig, deleteConfig } = useBalanceConfigs(); - const { setApiKey, removeApiKey, getApiKey } = useApiKeys(); + const { configs, addConfig, updateConfig, deleteConfig, loading } = useBalanceConfigs(); + const { setApiKey, removeApiKey, getApiKey } = useApiKeys(configs); const [dialogOpen, setDialogOpen] = useState(false); const [editingConfig, setEditingConfig] = useState(null); const [refreshingAll, setRefreshingAll] = useState(false); + const { toast } = useToast(); const { stateMap, refreshOne, refreshAll } = useBalanceMonitor(configs, getApiKey, true); @@ -55,9 +57,12 @@ export function BalancePage() { extractorScript: values.extractorScript, intervalSec: values.intervalSec ?? 0, timeoutMs: values.timeoutMs, + saveApiKey: values.saveApiKey ?? true, // 默认勾选保存 + apiKey: values.saveApiKey && values.apiKey?.trim() ? values.apiKey.trim() : undefined, // 根据选项保存 API Key updatedAt: now, }; updateConfig(next); + // 无论是否持久化,都在内存中更新 API Key if (values.apiKey?.trim()) { setApiKey(editingConfig.id, values.apiKey.trim()); } else { @@ -74,10 +79,13 @@ export function BalancePage() { extractorScript: values.extractorScript, intervalSec: values.intervalSec ?? 0, timeoutMs: values.timeoutMs, + saveApiKey: values.saveApiKey ?? true, // 默认勾选保存 + apiKey: values.saveApiKey && values.apiKey?.trim() ? values.apiKey.trim() : undefined, // 根据选项保存 API Key createdAt: now, updatedAt: now, }; addConfig(config); + // 内存中存储 API Key if (values.apiKey?.trim()) { setApiKey(id, values.apiKey.trim()); } @@ -98,6 +106,18 @@ export function BalancePage() { }; const handleRefreshAll = async () => { + // 检查是否有配置缺少 API Key + const configsWithoutKey = configs.filter((c) => !getApiKey(c.id)); + + if (configsWithoutKey.length > 0) { + const names = configsWithoutKey.map((c) => c.name).join('、'); + toast({ + title: '提示', + description: `以下配置缺少 API Key,将跳过查询:${names}`, + variant: 'default', + }); + } + setRefreshingAll(true); try { await refreshAll(); @@ -107,6 +127,14 @@ export function BalancePage() { }; const renderContent = () => { + if (loading) { + return ( +
+ +
+ ); + } + if (!sortedConfigs.length) { return setDialogOpen(true)} />; } @@ -134,7 +162,7 @@ export function BalancePage() {

余额监控

- 管理多个 API 余额配置,支持自定义提取器脚本(API Key 默认仅存内存) + 管理多个 API 余额配置,支持自定义提取器脚本(API Key 可选择保存到文件)

diff --git a/src/pages/BalancePage/types/index.ts b/src/pages/BalancePage/types/index.ts index ca5eec8..8c6addb 100644 --- a/src/pages/BalancePage/types/index.ts +++ b/src/pages/BalancePage/types/index.ts @@ -7,6 +7,8 @@ export interface BalanceConfig { extractorScript: string; // 提取器 JavaScript 代码 intervalSec?: number; // 0 或 undefined 表示不自动刷新 timeoutMs?: number; // 请求超时(毫秒) + saveApiKey?: boolean; // 是否保存 API Key 到文件(新增) + apiKey?: string; // API Key(可选,持久化时保存,新增) updatedAt: number; createdAt: number; } @@ -44,7 +46,8 @@ export interface BalanceFormValues { extractorScript: string; intervalSec?: number; timeoutMs?: number; - apiKey?: string; // 用于 Authorization header,不持久化 + apiKey?: string; // 用于 Authorization header + saveApiKey?: boolean; // 是否保存 API Key 到文件(新增) } // 预设模板类型