From 3819e7c28ed0f9eae709bbf9bcaaee25c3954d91 Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Fri, 2 Jan 2026 21:45:50 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BE=9B?= =?UTF-8?q?=E5=BA=94=E5=95=86=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F=EF=BC=88?= =?UTF-8?q?v1.5.0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 功能概述 实现完整的供应商(Provider)配置管理系统,支持多供应商账号管理、工具实例选择、用户配额与用量统计查询。 ## 后端实现 ### 数据模型(models/provider.rs) - Provider:供应商实体(id、name、website_url、user_id、access_token 等) - ToolInstanceSelection:工具实例选择记录(tool_id、instance_type) - ProviderStore:JSON 存储结构(version、providers、tool_instances) ### 核心服务(services/provider_manager.rs) - ProviderManager:供应商 CRUD 管理器,使用 DataManager 持久化 - 存储路径:~/.duckcoding/providers.json - 支持 LRU 缓存,读写自动刷新缓存 ### Tauri 命令(commands/provider_commands.rs) - list_providers:列出所有供应商 - create_provider/update_provider/delete_provider:增删改操作 - get_tool_instance_selection/set_tool_instance_selection:实例选择管理 - validate_provider_config:配置验证 ### 数据迁移(migrations/global_to_providers.rs) - 从 GlobalConfig.user_id/access_token 迁移到 providers.json - 自动创建名为 "DuckCoding" 的默认供应商 - 向后兼容,迁移后保留 GlobalConfig 旧字段(标记废弃) ### 统计查询增强(commands/stats_commands.rs) - get_user_quota:查询指定供应商的用户配额 - get_usage_stats:查询指定供应商的用量统计 - 新增 provider_id 参数支持多账号查询 ## 前端实现 ### 供应商管理页面(ProviderManagementPage/) - index.tsx:页面主体,供应商列表 + CRUD 操作 - ProviderFormDialog:创建/编辑表单(name、website、user_id、token) - DeleteConfirmDialog:删除确认对话框 - useProviderManagement:Hook 封装列表加载、增删改逻辑 ### Dashboard 改造(DashboardPage/) - ProviderTabs:供应商标签页切换组件 - useDashboardProviders:Hook 管理供应商状态和实例选择 - 集成配额卡片(QuotaCard)和统计卡片(TodayStatsCard) - 支持按供应商切换查看配额和用量数据 ### 组件增强 - DashboardToolCard:新增实例类型选择器(Local/WSL/SSH) - TodayStatsCard:支持传入 provider_id 查询指定账号统计 - TrendChartDialog:趋势图对话框组件(占位,待实现) - AppSidebar:新增"供应商管理"导航入口 ### 类型定义(types/provider.ts) - Provider、ProviderFormData、ToolInstanceSelection 完整类型 - Tauri 命令响应类型(CreateProviderResponse、ValidationResult 等) ### API 层(lib/tauri-commands/) - provider.ts:封装所有供应商管理 Tauri 命令 - api.ts:getUserQuota/getUsageStats 新增 providerId 参数 - types.ts:UserQuotaResult、UsageStatsResult 类型定义 ## 技术特性 - **数据隔离**:独立的 providers.json 存储,与全局配置解耦 - **默认迁移**:首次启动自动从 GlobalConfig 迁移数据 - **类型安全**:完整的 Rust + TypeScript 类型定义 - **缓存优化**:ProviderManager 内置 LRU 缓存,减少文件 I/O - **实例管理**:支持工具级别的环境选择(Local/WSL/SSH) ## 影响范围 - 后端:11 个文件(+792 行) - 前端:17 个文件(+1421 行) - 迁移逻辑:自动执行,向后兼容 - 破坏性变更:无(旧 API 保留) ## 测试建议 - 迁移测试:确认 GlobalConfig → providers.json 数据完整性 - CRUD 测试:创建/更新/删除供应商,验证 JSON 持久化 - 实例选择:切换工具实例类型,确认状态同步 - 多账号测试:创建多个供应商,验证配额查询隔离性 --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/provider_commands.rs | 239 +++++++++++++++++ src-tauri/src/commands/stats_commands.rs | 89 +++++-- src-tauri/src/main.rs | 11 + src-tauri/src/models/mod.rs | 2 + src-tauri/src/models/provider.rs | 129 +++++++++ .../migrations/global_to_providers.rs | 138 ++++++++++ .../migration_manager/migrations/mod.rs | 2 + .../src/services/migration_manager/mod.rs | 6 +- src-tauri/src/services/mod.rs | 3 + src-tauri/src/services/provider_manager.rs | 184 +++++++++++++ src/App.tsx | 87 +----- src/components/TodayStatsCard.tsx | 75 ++++-- src/components/TrendChartDialog.tsx | 23 ++ src/components/layout/AppSidebar.tsx | 4 +- src/lib/tauri-commands/api.ts | 10 +- src/lib/tauri-commands/index.ts | 3 + src/lib/tauri-commands/provider.ts | 70 +++++ src/lib/tauri-commands/types.ts | 16 ++ .../components/DashboardToolCard.tsx | 249 ++++++++++-------- .../DashboardPage/components/ProviderTabs.tsx | 138 ++++++++++ .../hooks/useDashboardProviders.ts | 94 +++++++ src/pages/DashboardPage/index.tsx | 242 ++++++++++++----- .../components/DeleteConfirmDialog.tsx | 55 ++++ .../components/ProviderFormDialog.tsx | 248 +++++++++++++++++ .../hooks/useProviderManagement.ts | 93 +++++++ src/pages/ProviderManagementPage/index.tsx | 230 ++++++++++++++++ src/types/provider.ts | 83 ++++++ 28 files changed, 2213 insertions(+), 312 deletions(-) create mode 100644 src-tauri/src/commands/provider_commands.rs create mode 100644 src-tauri/src/models/provider.rs create mode 100644 src-tauri/src/services/migration_manager/migrations/global_to_providers.rs create mode 100644 src-tauri/src/services/provider_manager.rs create mode 100644 src/components/TrendChartDialog.tsx create mode 100644 src/lib/tauri-commands/provider.ts create mode 100644 src/pages/DashboardPage/components/ProviderTabs.tsx create mode 100644 src/pages/DashboardPage/hooks/useDashboardProviders.ts create mode 100644 src/pages/ProviderManagementPage/components/DeleteConfirmDialog.tsx create mode 100644 src/pages/ProviderManagementPage/components/ProviderFormDialog.tsx create mode 100644 src/pages/ProviderManagementPage/hooks/useProviderManagement.ts create mode 100644 src/pages/ProviderManagementPage/index.tsx create mode 100644 src/types/provider.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 50c7d74..24de824 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod error; // 错误处理统一模块 pub mod log_commands; pub mod onboarding; pub mod profile_commands; // Profile 管理命令(v2.0) +pub mod provider_commands; // 供应商管理命令(v1.5.0) pub mod proxy_commands; pub mod session_commands; pub mod startup_commands; // 开机自启动管理命令 @@ -21,6 +22,7 @@ pub use config_commands::*; pub use log_commands::*; pub use onboarding::*; pub use profile_commands::*; // Profile 管理命令(v2.0) +pub use provider_commands::*; // 供应商管理命令(v1.5.0) pub use proxy_commands::*; pub use session_commands::*; pub use startup_commands::*; // 开机自启动管理命令 diff --git a/src-tauri/src/commands/provider_commands.rs b/src-tauri/src/commands/provider_commands.rs new file mode 100644 index 0000000..c210497 --- /dev/null +++ b/src-tauri/src/commands/provider_commands.rs @@ -0,0 +1,239 @@ +// Provider Commands +// +// 供应商管理 Tauri 命令 + +use ::duckcoding::models::provider::{Provider, ToolInstanceSelection}; +use ::duckcoding::services::ProviderManager; +use anyhow::Result; +use tauri::State; + +/// Provider 管理器 State +pub struct ProviderManagerState { + pub manager: ProviderManager, +} + +impl ProviderManagerState { + pub fn new() -> Self { + Self { + manager: ProviderManager::new().expect("Failed to create ProviderManager"), + } + } +} + +impl Default for ProviderManagerState { + fn default() -> Self { + Self::new() + } +} + +/// 列出所有供应商 +#[tauri::command] +pub async fn list_providers( + state: State<'_, ProviderManagerState>, +) -> Result, String> { + state + .manager + .list_providers() + .map_err(|e| format!("获取供应商列表失败: {}", e)) +} + +/// 创建新供应商 +#[tauri::command] +pub async fn create_provider( + provider: Provider, + state: State<'_, ProviderManagerState>, +) -> Result { + // 基础验证 + if provider.id.is_empty() { + return Err("供应商 ID 不能为空".to_string()); + } + if provider.name.is_empty() { + return Err("供应商名称不能为空".to_string()); + } + if provider.website_url.is_empty() { + return Err("官网地址不能为空".to_string()); + } + + state + .manager + .create_provider(provider) + .map_err(|e| format!("创建供应商失败: {}", e)) +} + +/// 更新供应商 +#[tauri::command] +pub async fn update_provider( + id: String, + provider: Provider, + state: State<'_, ProviderManagerState>, +) -> Result { + // 基础验证 + if provider.name.is_empty() { + return Err("供应商名称不能为空".to_string()); + } + if provider.website_url.is_empty() { + return Err("官网地址不能为空".to_string()); + } + + state + .manager + .update_provider(&id, provider) + .map_err(|e| format!("更新供应商失败: {}", e)) +} + +/// 删除供应商 +#[tauri::command] +pub async fn delete_provider( + id: String, + state: State<'_, ProviderManagerState>, +) -> Result<(), String> { + if id.is_empty() { + return Err("供应商 ID 不能为空".to_string()); + } + + state + .manager + .delete_provider(&id) + .map_err(|e| format!("删除供应商失败: {}", e)) +} + +/// 获取工具实例选择 +#[tauri::command] +pub async fn get_tool_instance_selection( + tool_id: String, + state: State<'_, ProviderManagerState>, +) -> Result, String> { + if tool_id.is_empty() { + return Err("工具 ID 不能为空".to_string()); + } + + state + .manager + .get_tool_instance(&tool_id) + .map_err(|e| format!("获取工具实例选择失败: {}", e)) +} + +/// 设置工具实例选择 +#[tauri::command] +pub async fn set_tool_instance_selection( + selection: ToolInstanceSelection, + state: State<'_, ProviderManagerState>, +) -> Result<(), String> { + // 验证参数 + if selection.tool_id.is_empty() { + return Err("工具 ID 不能为空".to_string()); + } + if selection.instance_type.is_empty() { + return Err("实例类型不能为空".to_string()); + } + + // 验证实例类型 + match selection.instance_type.as_str() { + "local" | "wsl" | "ssh" => {} + _ => return Err("无效的实例类型,必须是 local、wsl 或 ssh".to_string()), + } + + // SSH 实例必须提供路径 + if selection.instance_type == "ssh" && selection.instance_path.is_none() { + return Err("SSH 实例必须提供实例路径".to_string()); + } + + state + .manager + .set_tool_instance(selection) + .map_err(|e| format!("设置工具实例选择失败: {}", e)) +} + +/// 验证结果结构 +#[derive(serde::Serialize)] +pub struct ValidationResult { + pub success: bool, + pub username: Option, + pub error: Option, +} + +/// 验证供应商配置(检查 API 连通性) +#[tauri::command] +pub async fn validate_provider_config(provider: Provider) -> Result { + use reqwest::Client; + use std::time::Duration; + + // 基础验证 + if provider.website_url.is_empty() { + return Ok(ValidationResult { + success: false, + username: None, + error: Some("官网地址不能为空".to_string()), + }); + } + if provider.user_id.is_empty() { + return Ok(ValidationResult { + success: false, + username: None, + error: Some("用户 ID 不能为空".to_string()), + }); + } + if provider.access_token.is_empty() { + return Ok(ValidationResult { + success: false, + username: None, + error: Some("访问令牌不能为空".to_string()), + }); + } + + // 构建 API 端点 + let api_url = format!( + "{}/api/user/self", + provider.website_url.trim_end_matches('/') + ); + + // 发送验证请求 + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; + + let response = client + .get(&api_url) + .header("Authorization", format!("Bearer {}", provider.access_token)) + .header("New-Api-User", &provider.user_id) + .send() + .await + .map_err(|e| format!("API 请求失败: {}", e))?; + + if response.status().is_success() { + // 尝试解析响应,提取用户名 + let json_result = response.json::().await; + match json_result { + Ok(json) => { + // 尝试从响应中提取用户名 (假设在 data.username 或 username 字段) + let username = json + .get("data") + .and_then(|data| data.get("username")) + .or_else(|| json.get("username")) + .and_then(|u| u.as_str()) + .map(|s| s.to_string()); + + Ok(ValidationResult { + success: true, + username, + error: None, + }) + } + Err(e) => Ok(ValidationResult { + success: false, + username: None, + error: Some(format!("API 响应格式错误: {}", e)), + }), + } + } else { + Ok(ValidationResult { + success: false, + username: None, + error: Some(format!( + "API 验证失败,状态码: {}", + response.status().as_u16() + )), + }) + } +} diff --git a/src-tauri/src/commands/stats_commands.rs b/src-tauri/src/commands/stats_commands.rs index 4165ab4..a734333 100644 --- a/src-tauri/src/commands/stats_commands.rs +++ b/src-tauri/src/commands/stats_commands.rs @@ -2,9 +2,10 @@ // // 包含用量统计、用户额度查询等功能 +use crate::commands::provider_commands::ProviderManagerState; use ::duckcoding::services::proxy::config::apply_global_proxy; -use ::duckcoding::utils::config::read_global_config; use serde::Serialize; +use tauri::State; /// 用量统计数据结构 #[derive(serde::Deserialize, Serialize, Debug, Clone)] @@ -64,10 +65,28 @@ fn build_reqwest_client() -> Result { } #[tauri::command] -pub async fn get_usage_stats() -> Result { +pub async fn get_usage_stats( + provider_id: String, + provider_state: State<'_, ProviderManagerState>, +) -> Result { apply_global_proxy().ok(); - let global_config = - read_global_config()?.ok_or_else(|| "请先配置用户ID和系统访问令牌".to_string())?; + + // 根据 provider_id 获取供应商 + let providers = provider_state + .manager + .list_providers() + .map_err(|e| format!("获取供应商列表失败: {}", e))?; + + let provider = providers + .iter() + .find(|p| p.id == provider_id) + .ok_or_else(|| format!("未找到供应商: {}", provider_id))?; + + // 验证供应商凭证 + if provider.user_id.is_empty() || provider.access_token.is_empty() { + return Err("请先配置供应商的用户ID和访问令牌".to_string()); + } + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() @@ -77,9 +96,13 @@ pub async fn get_usage_stats() -> Result { let start_timestamp = today_end - 30 * 86400; let end_timestamp = today_end; let client = build_reqwest_client().map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; + + // 使用供应商的 website_url let url = format!( - "https://duckcoding.com/api/data/self?start_timestamp={start_timestamp}&end_timestamp={end_timestamp}" + "{}/api/data/self?start_timestamp={start_timestamp}&end_timestamp={end_timestamp}", + provider.website_url.trim_end_matches('/') ); + let response = client .get(&url) .header( @@ -88,13 +111,10 @@ pub async fn get_usage_stats() -> Result { ) .header("Accept", "application/json, text/plain, */*") .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") - .header("Referer", "https://duckcoding.com/") - .header("Origin", "https://duckcoding.com") - .header( - "Authorization", - format!("Bearer {}", global_config.system_token), - ) - .header("New-Api-User", &global_config.user_id) + .header("Referer", &provider.website_url) + .header("Origin", &provider.website_url) + .header("Authorization", format!("Bearer {}", provider.access_token)) + .header("New-Api-User", &provider.user_id) .send() .await .map_err(|e| format!("获取用量统计失败: {e}"))?; @@ -139,27 +159,48 @@ pub async fn get_usage_stats() -> Result { } #[tauri::command] -pub async fn get_user_quota() -> Result { +pub async fn get_user_quota( + provider_id: String, + provider_state: State<'_, ProviderManagerState>, +) -> Result { apply_global_proxy().ok(); - let global_config = - read_global_config()?.ok_or_else(|| "请先配置用户ID和系统访问令牌".to_string())?; + + // 根据 provider_id 获取供应商 + let providers = provider_state + .manager + .list_providers() + .map_err(|e| format!("获取供应商列表失败: {}", e))?; + + let provider = providers + .iter() + .find(|p| p.id == provider_id) + .ok_or_else(|| format!("未找到供应商: {}", provider_id))?; + + // 验证供应商凭证 + if provider.user_id.is_empty() || provider.access_token.is_empty() { + return Err("请先配置供应商的用户ID和访问令牌".to_string()); + } + let client = build_reqwest_client().map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; - let url = "https://duckcoding.com/api/user/self"; + + // 使用供应商的 website_url + let url = format!( + "{}/api/user/self", + provider.website_url.trim_end_matches('/') + ); + let response = client - .get(url) + .get(&url) .header( "User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", ) .header("Accept", "application/json, text/plain, */*") .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") - .header("Referer", "https://duckcoding.com/") - .header("Origin", "https://duckcoding.com") - .header( - "Authorization", - format!("Bearer {}", global_config.system_token), - ) - .header("New-Api-User", &global_config.user_id) + .header("Referer", &provider.website_url) + .header("Origin", &provider.website_url) + .header("Authorization", format!("Bearer {}", provider.access_token)) + .header("New-Api-User", &provider.user_id) .send() .await .map_err(|e| format!("获取用户信息失败: {e}"))?; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 55e1552..376670a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -199,6 +199,8 @@ fn main() { manager: init_ctx.profile_manager, }; + let provider_manager_state = ProviderManagerState::new(); + // 判断单实例模式 let single_instance_enabled = determine_single_instance_mode(); @@ -214,6 +216,7 @@ fn main() { .manage(update_service_state) .manage(tool_registry_state) .manage(profile_manager_state) + .manage(provider_manager_state) .setup(|app| { setup_app_hooks(app)?; Ok(()) @@ -364,6 +367,14 @@ fn main() { pm_get_active_profile_name, pm_get_active_profile, pm_capture_from_native, + // 供应商管理命令(v1.5.0) + list_providers, + create_provider, + update_provider, + delete_provider, + get_tool_instance_selection, + set_tool_instance_selection, + validate_provider_config, ]); // 使用自定义事件循环处理 macOS Reopen 事件 diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index a7d3a37..e05c50c 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,11 +1,13 @@ pub mod balance; pub mod config; +pub mod provider; pub mod proxy_config; pub mod tool; pub mod update; pub use balance::*; pub use config::*; +pub use provider::*; // 只导出新的 proxy_config 类型,避免与 config.rs 中的旧类型冲突 pub use proxy_config::{ProxyMetadata, ProxyStore}; pub use tool::*; diff --git a/src-tauri/src/models/provider.rs b/src-tauri/src/models/provider.rs new file mode 100644 index 0000000..2beac36 --- /dev/null +++ b/src-tauri/src/models/provider.rs @@ -0,0 +1,129 @@ +// Provider Configuration Models +// +// 供应商配置数据模型 + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// 供应商配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Provider { + /// 唯一标识 + pub id: String, + /// 供应商名称(如 DuckCoding) + pub name: String, + /// 官网地址 + pub website_url: String, + /// 用户ID + pub user_id: String, + /// 系统访问令牌 + pub access_token: String, + /// 用户名(可选,用于确认) + pub username: Option, + /// 是否为默认供应商 + pub is_default: bool, + /// 创建时间 + pub created_at: i64, + /// 更新时间 + pub updated_at: i64, +} + +/// 工具实例选择记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInstanceSelection { + /// 工具ID ("claude-code" | "codex" | "gemini-cli") + pub tool_id: String, + /// 实例类型 ("local" | "wsl" | "ssh") + pub instance_type: String, + /// SSH 实例的路径(可选) + pub instance_path: Option, +} + +/// 供应商存储结构 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderStore { + /// 数据版本 + pub version: u32, + /// 供应商列表 + pub providers: Vec, + /// 工具实例选择记录 + pub tool_instances: HashMap, + /// 最后更新时间 + pub updated_at: i64, +} + +impl Default for ProviderStore { + fn default() -> Self { + let now = chrono::Utc::now().timestamp(); + Self { + version: 1, + providers: vec![Provider { + id: "duckcoding".to_string(), + name: "DuckCoding".to_string(), + website_url: "https://duckcoding.com".to_string(), + user_id: String::new(), + access_token: String::new(), + username: None, + is_default: true, + created_at: now, + updated_at: now, + }], + tool_instances: HashMap::new(), + updated_at: now, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_provider_store() { + let store = ProviderStore::default(); + assert_eq!(store.version, 1); + assert_eq!(store.providers.len(), 1); + assert_eq!(store.providers[0].id, "duckcoding"); + assert_eq!(store.providers[0].name, "DuckCoding"); + assert!(store.providers[0].is_default); + assert!(store.tool_instances.is_empty()); + } + + #[test] + fn test_provider_serialization() { + let provider = Provider { + id: "test".to_string(), + name: "Test Provider".to_string(), + website_url: "https://test.com".to_string(), + user_id: "12345".to_string(), + access_token: "token123".to_string(), + username: Some("testuser".to_string()), + is_default: false, + created_at: 1234567890, + updated_at: 1234567890, + }; + + let json = serde_json::to_string(&provider).unwrap(); + let deserialized: Provider = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.id, provider.id); + assert_eq!(deserialized.name, provider.name); + assert_eq!(deserialized.username, provider.username); + } + + #[test] + fn test_tool_instance_selection() { + let selection = ToolInstanceSelection { + tool_id: "claude-code".to_string(), + instance_type: "local".to_string(), + instance_path: None, + }; + + let json = serde_json::to_string(&selection).unwrap(); + let deserialized: ToolInstanceSelection = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.tool_id, "claude-code"); + assert_eq!(deserialized.instance_type, "local"); + assert!(deserialized.instance_path.is_none()); + } +} diff --git a/src-tauri/src/services/migration_manager/migrations/global_to_providers.rs b/src-tauri/src/services/migration_manager/migrations/global_to_providers.rs new file mode 100644 index 0000000..6964e36 --- /dev/null +++ b/src-tauri/src/services/migration_manager/migrations/global_to_providers.rs @@ -0,0 +1,138 @@ +// GlobalConfig 迁移到 Providers.json +// +// 将用户信息从 GlobalConfig 迁移到独立的 providers.json 存储 + +use crate::data::DataManager; +use crate::models::provider::ProviderStore; +use crate::services::migration_manager::migration_trait::{Migration, MigrationResult}; +use crate::utils::config::{config_dir, read_global_config}; +use anyhow::Result; +use async_trait::async_trait; + +/// GlobalConfig 迁移到 Providers.json(目标版本 1.5.0) +pub struct GlobalConfigToProvidersMigration; + +impl Default for GlobalConfigToProvidersMigration { + fn default() -> Self { + Self::new() + } +} + +impl GlobalConfigToProvidersMigration { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Migration for GlobalConfigToProvidersMigration { + fn id(&self) -> &str { + "global_config_to_providers_v1" + } + + fn name(&self) -> &str { + "GlobalConfig 用户信息迁移到 Providers" + } + + fn target_version(&self) -> &str { + "1.5.0" + } + + async fn execute(&self) -> Result { + tracing::info!("开始执行 GlobalConfig → Providers 迁移"); + + let data_manager = DataManager::new(); + let providers_path = config_dir() + .map_err(|e| anyhow::anyhow!("获取配置目录失败: {}", e))? + .join("providers.json"); + + // 检查是否已迁移 + if providers_path.exists() { + tracing::info!("providers.json 已存在,跳过迁移"); + return Ok(MigrationResult { + migration_id: self.id().to_string(), + success: true, + message: "已迁移,跳过".to_string(), + records_migrated: 0, + duration_secs: 0.0, + }); + } + + // 读取 GlobalConfig + let global_config = match read_global_config() { + Ok(Some(cfg)) => cfg, + Ok(None) | Err(_) => { + // 如果没有配置或读取失败,创建默认 ProviderStore + let store = ProviderStore::default(); + let json_value = serde_json::to_value(&store) + .map_err(|e| anyhow::anyhow!("序列化 ProviderStore 失败: {}", e))?; + data_manager.json().write(&providers_path, &json_value)?; + return Ok(MigrationResult { + migration_id: self.id().to_string(), + success: true, + message: "创建默认 Providers 配置(无用户信息)".to_string(), + records_migrated: 1, + duration_secs: 0.0, + }); + } + }; + + // 创建默认 ProviderStore + let mut store = ProviderStore::default(); + + // 如果 GlobalConfig 中有用户信息,填充到默认 DuckCoding 供应商 + if !global_config.user_id.is_empty() || !global_config.system_token.is_empty() { + if let Some(provider) = store.providers.get_mut(0) { + provider.user_id = global_config.user_id.clone(); + provider.access_token = global_config.system_token.clone(); + provider.updated_at = chrono::Utc::now().timestamp(); + + tracing::info!( + "迁移用户信息: user_id={}, token={}", + if provider.user_id.is_empty() { + "未配置" + } else { + "已配置" + }, + if provider.access_token.is_empty() { + "未配置" + } else { + "已配置" + } + ); + } + } + + // 写入 providers.json + let json_value = serde_json::to_value(&store) + .map_err(|e| anyhow::anyhow!("序列化 ProviderStore 失败: {}", e))?; + data_manager.json().write(&providers_path, &json_value)?; + + let message = if global_config.user_id.is_empty() { + "创建默认 Providers 配置(无用户信息)" + } else { + "成功迁移 GlobalConfig 用户信息到 Providers" + }; + + Ok(MigrationResult { + migration_id: self.id().to_string(), + success: true, + message: message.to_string(), + records_migrated: 1, + duration_secs: 0.0, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_migration_creates_default_store() { + let migration = GlobalConfigToProvidersMigration::new(); + assert_eq!(migration.id(), "global_config_to_providers_v1"); + assert_eq!(migration.name(), "GlobalConfig 用户信息迁移到 Providers"); + assert_eq!(migration.target_version(), "1.5.0"); + } +} diff --git a/src-tauri/src/services/migration_manager/migrations/mod.rs b/src-tauri/src/services/migration_manager/migrations/mod.rs index 5641feb..3f90f11 100644 --- a/src-tauri/src/services/migration_manager/migrations/mod.rs +++ b/src-tauri/src/services/migration_manager/migrations/mod.rs @@ -3,6 +3,7 @@ // 每个迁移定义目标版本号,按版本号顺序执行 mod balance_localstorage_to_json; +mod global_to_providers; mod profile_v2; mod proxy_config; mod proxy_config_split; @@ -10,6 +11,7 @@ mod session_config; mod sqlite_to_json; pub use balance_localstorage_to_json::BalanceLocalstorageToJsonMigration; +pub use global_to_providers::GlobalConfigToProvidersMigration; 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 c089439..c36c91c 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::{ - BalanceLocalstorageToJsonMigration, ProfileV2Migration, ProxyConfigMigration, - ProxyConfigSplitMigration, SessionConfigMigration, SqliteToJsonMigration, + BalanceLocalstorageToJsonMigration, GlobalConfigToProvidersMigration, ProfileV2Migration, + ProxyConfigMigration, ProxyConfigSplitMigration, SessionConfigMigration, SqliteToJsonMigration, }; use std::sync::Arc; @@ -24,6 +24,7 @@ use std::sync::Arc; /// - ProfileV2Migration (1.4.0) - Profile v2.0 双文件系统迁移 /// - ProxyConfigSplitMigration (1.4.0) - 透明代理配置拆分到 proxy.json /// - BalanceLocalstorageToJsonMigration (1.4.1) - 余额监控 LocalStorage → JSON 迁移 +/// - GlobalConfigToProvidersMigration (1.5.0) - GlobalConfig 用户信息迁移到 Providers pub fn create_migration_manager() -> MigrationManager { let mut manager = MigrationManager::new(); @@ -34,6 +35,7 @@ pub fn create_migration_manager() -> MigrationManager { manager.register(Arc::new(ProfileV2Migration::new())); manager.register(Arc::new(ProxyConfigSplitMigration::new())); manager.register(Arc::new(BalanceLocalstorageToJsonMigration::new())); + manager.register(Arc::new(GlobalConfigToProvidersMigration::new())); tracing::debug!( "迁移管理器初始化完成,已注册 {} 个迁移", diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index edd9d80..d6c3d9e 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -8,11 +8,13 @@ // - session: 会话管理(透明代理请求追踪) // - migration_manager: 统一迁移管理(新) // - balance: 余额监控配置管理 +// - provider_manager: 供应商配置管理 pub mod balance; pub mod config; pub mod migration_manager; pub mod profile_manager; // Profile管理(v2.1) +pub mod provider_manager; // 供应商配置管理 pub mod proxy; pub mod proxy_config_manager; // 透明代理配置管理(v2.1) pub mod session; @@ -27,6 +29,7 @@ pub use profile_manager::{ ActiveStore, ClaudeProfile, CodexProfile, GeminiProfile, ProfileDescriptor, ProfileManager, ProfilesStore, }; // Profile管理(v2.0) +pub use provider_manager::ProviderManager; pub use proxy::*; // session 模块:明确导出避免 db 名称冲突 pub use session::{manager::SESSION_MANAGER, models::*}; diff --git a/src-tauri/src/services/provider_manager.rs b/src-tauri/src/services/provider_manager.rs new file mode 100644 index 0000000..23f98cf --- /dev/null +++ b/src-tauri/src/services/provider_manager.rs @@ -0,0 +1,184 @@ +// Provider Manager Service +// +// 供应商配置管理服务 + +use crate::data::DataManager; +use crate::models::provider::{Provider, ProviderStore, ToolInstanceSelection}; +use crate::utils::config::config_dir; +use anyhow::{anyhow, Result}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +/// 供应商管理器 +pub struct ProviderManager { + data_manager: Arc, + store_path: PathBuf, + cache: Arc>>, +} + +impl ProviderManager { + /// 创建新的 ProviderManager 实例 + pub fn new() -> Result { + let data_manager = Arc::new(DataManager::new()); + let store_path = config_dir() + .map_err(|e| anyhow::anyhow!("获取配置目录失败: {}", e))? + .join("providers.json"); + + Ok(Self { + data_manager, + store_path, + cache: Arc::new(Mutex::new(None)), + }) + } + + /// 读取存储(带缓存) + pub fn load_store(&self) -> Result { + // 检查缓存 + if let Some(cached) = self.cache.lock().unwrap().as_ref() { + return Ok(cached.clone()); + } + + // 文件不存在则返回默认值(迁移会创建) + if !self.store_path.exists() { + tracing::warn!("providers.json 不存在,返回默认配置"); + return Ok(ProviderStore::default()); + } + + // 从文件读取 + let json_value = self.data_manager.json().read(&self.store_path)?; + let store: ProviderStore = serde_json::from_value(json_value) + .map_err(|e| anyhow::anyhow!("反序列化 ProviderStore 失败: {}", e))?; + + // 更新缓存 + *self.cache.lock().unwrap() = Some(store.clone()); + + Ok(store) + } + + /// 保存存储 + fn save_store(&self, store: &ProviderStore) -> Result<()> { + let json_value = serde_json::to_value(store) + .map_err(|e| anyhow::anyhow!("序列化 ProviderStore 失败: {}", e))?; + self.data_manager + .json() + .write(&self.store_path, &json_value)?; + *self.cache.lock().unwrap() = Some(store.clone()); + Ok(()) + } + + /// 列出所有供应商 + pub fn list_providers(&self) -> Result> { + Ok(self.load_store()?.providers) + } + + /// 创建供应商 + pub fn create_provider(&self, mut provider: Provider) -> Result { + let mut store = self.load_store()?; + + // 检查 ID 冲突 + if store.providers.iter().any(|p| p.id == provider.id) { + return Err(anyhow!("供应商 ID 已存在: {}", provider.id)); + } + + let now = chrono::Utc::now().timestamp(); + provider.created_at = now; + provider.updated_at = now; + + store.providers.push(provider.clone()); + store.updated_at = now; + + self.save_store(&store)?; + Ok(provider) + } + + /// 更新供应商 + pub fn update_provider(&self, id: &str, updated: Provider) -> Result { + let mut store = self.load_store()?; + + let provider = store + .providers + .iter_mut() + .find(|p| p.id == id) + .ok_or_else(|| anyhow!("供应商不存在: {}", id))?; + + provider.name = updated.name; + provider.website_url = updated.website_url; + provider.user_id = updated.user_id; + provider.access_token = updated.access_token; + provider.username = updated.username; + provider.updated_at = chrono::Utc::now().timestamp(); + + let updated_at = provider.updated_at; + let result = provider.clone(); + + store.updated_at = updated_at; + self.save_store(&store)?; + + Ok(result) + } + + /// 删除供应商 + pub fn delete_provider(&self, id: &str) -> Result<()> { + let mut store = self.load_store()?; + + // 不允许删除默认供应商 + if store.providers.iter().any(|p| p.id == id && p.is_default) { + return Err(anyhow!("无法删除默认供应商")); + } + + store.providers.retain(|p| p.id != id); + store.updated_at = chrono::Utc::now().timestamp(); + self.save_store(&store)?; + + Ok(()) + } + + /// 获取工具实例选择 + pub fn get_tool_instance(&self, tool_id: &str) -> Result> { + Ok(self.load_store()?.tool_instances.get(tool_id).cloned()) + } + + /// 设置工具实例选择 + pub fn set_tool_instance(&self, selection: ToolInstanceSelection) -> Result<()> { + let mut store = self.load_store()?; + + store + .tool_instances + .insert(selection.tool_id.clone(), selection); + store.updated_at = chrono::Utc::now().timestamp(); + + self.save_store(&store)?; + Ok(()) + } + + /// 清除缓存(用于测试或强制刷新) + pub fn clear_cache(&self) { + *self.cache.lock().unwrap() = None; + } +} + +impl Default for ProviderManager { + fn default() -> Self { + Self::new().expect("Failed to create ProviderManager") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provider_manager_creation() { + let manager = ProviderManager::new(); + assert!(manager.is_ok()); + } + + #[test] + fn test_load_default_store() { + let manager = ProviderManager::new().unwrap(); + let store = manager.load_store().unwrap(); + assert_eq!(store.version, 1); + assert_eq!(store.providers.len(), 1); + assert_eq!(store.providers[0].id, "duckcoding"); + } +} diff --git a/src/App.tsx b/src/App.tsx index 494a05d..5bd5cd0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,10 +4,10 @@ import { invoke } from '@tauri-apps/api/core'; import { AppSidebar } from '@/components/layout/AppSidebar'; import { CloseActionDialog } from '@/components/dialogs/CloseActionDialog'; import { UpdateDialog } from '@/components/dialogs/UpdateDialog'; -import { StatisticsPage } from '@/pages/StatisticsPage'; import { InstallationPage } from '@/pages/InstallationPage'; import { DashboardPage } from '@/pages/DashboardPage'; import ProfileManagementPage from '@/pages/ProfileManagementPage'; +import { ProviderManagementPage } from '@/pages/ProviderManagementPage'; import { SettingsPage } from '@/pages/SettingsPage'; import { TransparentProxyPage } from '@/pages/TransparentProxyPage'; import { ToolManagementPage } from '@/pages/ToolManagementPage'; @@ -28,13 +28,9 @@ import { checkInstallations, checkForAppUpdates, getGlobalConfig, - getUserQuota, - getUsageStats, type CloseAction, type ToolStatus, type GlobalConfig, - type UserQuotaResult, - type UsageStatsResult, type UpdateInfo, } from '@/lib/tauri-commands'; @@ -43,9 +39,9 @@ type TabType = | 'tool-management' | 'install' | 'profile-management' - | 'statistics' | 'balance' | 'transparent-proxy' + | 'provider-management' | 'settings' | 'help'; @@ -67,17 +63,10 @@ function App() { const [tools, setTools] = useState([]); const [toolsLoading, setToolsLoading] = useState(true); - // 全局配置缓存(供 StatisticsPage 和 SettingsPage 共享) + // 全局配置缓存(供 SettingsPage 使用) const [globalConfig, setGlobalConfig] = useState(null); const [configLoading, setConfigLoading] = useState(false); - // 统计数据缓存 - const [usageStats, setUsageStats] = useState(null); - const [userQuota, setUserQuota] = useState(null); - const [statsLoading, setStatsLoading] = useState(false); - const [statsLoadFailed, setStatsLoadFailed] = useState(false); // 新增:记录加载失败状态 - const [statsError, setStatsError] = useState(null); - // 更新检查状态 const [updateInfo, setUpdateInfo] = useState(null); const [updateCheckDone, setUpdateCheckDone] = useState(false); @@ -109,37 +98,6 @@ function App() { } }, []); - // 加载统计数据(仅在需要时调用) - const loadStatistics = useCallback(async () => { - if (!globalConfig?.user_id || !globalConfig?.system_token) { - return; - } - - try { - setStatsLoading(true); - setStatsLoadFailed(false); - setStatsError(null); - const [quota, stats] = await Promise.all([getUserQuota(), getUsageStats()]); - setUserQuota(quota); - setUsageStats(stats); - setStatsLoadFailed(false); - } catch (error) { - console.error('Failed to load statistics:', error); - setStatsLoadFailed(true); - const message = error instanceof Error ? error.message : '请检查网络连接后重试'; - setStatsError(message); - - toast({ - title: '加载统计数据失败', - description: message, - variant: 'destructive', - duration: 5000, - }); - } finally { - setStatsLoading(false); - } - }, [globalConfig?.user_id, globalConfig?.system_token, toast]); - // 检查应用更新 const checkAppUpdates = useCallback( async (force = false) => { @@ -240,12 +198,6 @@ function App() { return () => clearTimeout(timer); }, [checkAppUpdates]); - // 当凭证变更时,重置统计状态以便重新加载 - useEffect(() => { - setStatsLoadFailed(false); - setStatsError(null); - }, [globalConfig?.user_id, globalConfig?.system_token]); - // 监听后端推送的更新事件 useEffect(() => { // 监听后端主动推送的更新可用事件 @@ -332,27 +284,6 @@ function App() { }; }, [toast]); - // 智能预加载:只要有凭证就立即预加载统计数据 - useEffect(() => { - // 条件:配置已加载 + 有凭证 + 还没有统计数据 + 不在加载中 + 没有失败过 - if ( - globalConfig?.user_id && - globalConfig?.system_token && - !usageStats && - !statsLoading && - !statsLoadFailed - ) { - loadStatistics(); - } - }, [ - globalConfig?.user_id, - globalConfig?.system_token, - usageStats, - statsLoading, - statsLoadFailed, - loadStatistics, - ]); - // 使用关闭动作 Hook const { closeDialogOpen, @@ -429,17 +360,6 @@ function App() { {activeTab === 'install' && } {activeTab === 'balance' && } {activeTab === 'profile-management' && } - {activeTab === 'statistics' && ( - - )} {activeTab === 'transparent-proxy' && ( )} @@ -458,6 +378,7 @@ function App() { }} /> )} + {activeTab === 'provider-management' && } {activeTab === 'help' && } diff --git a/src/components/TodayStatsCard.tsx b/src/components/TodayStatsCard.tsx index b685a48..39105c7 100644 --- a/src/components/TodayStatsCard.tsx +++ b/src/components/TodayStatsCard.tsx @@ -1,9 +1,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Calendar } from 'lucide-react'; -import { useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Calendar, TrendingUp } from 'lucide-react'; +import { useMemo, useState } from 'react'; import { format } from 'date-fns'; import { zhCN } from 'date-fns/locale'; import type { UsageStatsResult } from '@/lib/tauri-commands'; +import { TrendChartDialog } from './TrendChartDialog'; interface TodayStatsCardProps { stats: UsageStatsResult | null; @@ -11,6 +13,8 @@ interface TodayStatsCardProps { } export function TodayStatsCard({ stats, loading }: TodayStatsCardProps) { + const [showTrendDialog, setShowTrendDialog] = useState(false); + // 计算今日数据 const todayStats = useMemo(() => { if (!stats?.data || stats.data.length === 0) { @@ -83,30 +87,53 @@ export function TodayStatsCard({ stats, loading }: TodayStatsCardProps) { const today = format(new Date(), 'yyyy年MM月dd日', { locale: zhCN }); return ( - - - - - 今日用量 - -

{today}

-
- -
+ <> + +
- 请求次数 - - {todayStats.requests.toLocaleString()} - +
+ + + 今日用量 + +

{today}

+
+
-
- 消费额度 - - {formatQuota(todayStats.quota)} - + + +
+
+ 请求次数 + + {todayStats.requests.toLocaleString()} + +
+
+ 消费额度 + + {formatQuota(todayStats.quota)} + +
-
- -
+ + + + {/* 趋势图表弹窗 */} + + ); } diff --git a/src/components/TrendChartDialog.tsx b/src/components/TrendChartDialog.tsx new file mode 100644 index 0000000..b0839bb --- /dev/null +++ b/src/components/TrendChartDialog.tsx @@ -0,0 +1,23 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { UsageChart } from '@/components/UsageChart'; +import type { UsageStatsResult } from '@/lib/tauri-commands'; + +interface TrendChartDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + stats: UsageStatsResult | null; + loading: boolean; +} + +export function TrendChartDialog({ open, onOpenChange, stats, loading }: TrendChartDialogProps) { + return ( + + + + 用量趋势 + + + + + ); +} diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index e740816..d56ea0e 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -6,11 +6,11 @@ import { LayoutDashboard, Wrench, Settings2, - BarChart3, Wallet, Radio, Settings as SettingsIcon, HelpCircle, + Building2, ChevronsLeft, ChevronsRight, Sun, @@ -45,12 +45,12 @@ const navigationItems = [ { id: 'dashboard', label: '仪表板', icon: LayoutDashboard }, { id: 'tool-management', label: '工具管理', icon: Wrench }, { id: 'profile-management', label: '配置管理', icon: Settings2 }, - { id: 'statistics', label: '用量统计', icon: BarChart3 }, { id: 'balance', label: '余额查询', icon: Wallet }, { id: 'transparent-proxy', label: '透明代理', icon: Radio }, ]; const secondaryItems = [ + { id: 'provider-management', label: '供应商管理', icon: Building2 }, { id: 'help', label: '帮助', icon: HelpCircle }, { id: 'settings', label: '设置', icon: SettingsIcon }, ]; diff --git a/src/lib/tauri-commands/api.ts b/src/lib/tauri-commands/api.ts index 05d28ad..c6e0a3a 100644 --- a/src/lib/tauri-commands/api.ts +++ b/src/lib/tauri-commands/api.ts @@ -13,16 +13,18 @@ export async function generateApiKeyForTool(tool: string): Promise { - return await invoke('get_usage_stats'); +export async function getUsageStats(providerId: string): Promise { + return await invoke('get_usage_stats', { providerId }); } /** * 获取用户配额 + * @param providerId - 供应商 ID */ -export async function getUserQuota(): Promise { - return await invoke('get_user_quota'); +export async function getUserQuota(providerId: string): Promise { + return await invoke('get_user_quota', { providerId }); } /** diff --git a/src/lib/tauri-commands/index.ts b/src/lib/tauri-commands/index.ts index 5953faa..6891c72 100644 --- a/src/lib/tauri-commands/index.ts +++ b/src/lib/tauri-commands/index.ts @@ -16,6 +16,9 @@ export * from './proxy'; // Profile 管理 export * from './profile'; +// 供应商管理 +export * from './provider'; + // 会话管理 export * from './session'; diff --git a/src/lib/tauri-commands/provider.ts b/src/lib/tauri-commands/provider.ts new file mode 100644 index 0000000..a358400 --- /dev/null +++ b/src/lib/tauri-commands/provider.ts @@ -0,0 +1,70 @@ +// 供应商管理命令模块 +// 负责供应商的 CRUD、验证、工具实例选择 + +import { invoke } from '@tauri-apps/api/core'; +import type { + Provider, + ToolInstanceSelection, + _ProviderFormData, + ProviderValidationResult, +} from './types'; + +/** + * 列出所有供应商 + */ +export async function listProviders(): Promise { + return invoke('list_providers'); +} + +/** + * 创建新供应商 + */ +export async function createProvider(provider: Provider): Promise { + return invoke('create_provider', { provider }); +} + +/** + * 更新供应商 + */ +export async function updateProvider(id: string, provider: Provider): Promise { + return invoke('update_provider', { id, provider }); +} + +/** + * 删除供应商 + */ +export async function deleteProvider(id: string): Promise { + return invoke('delete_provider', { id }); +} + +/** + * 获取工具实例选择 + */ +export async function getToolInstanceSelection( + toolId: string, +): Promise { + return invoke('get_tool_instance_selection', { toolId }); +} + +/** + * 设置工具实例选择 + */ +export async function setToolInstanceSelection(selection: ToolInstanceSelection): Promise { + return invoke('set_tool_instance_selection', { selection }); +} + +/** + * 验证供应商配置(检查 API 连通性,获取用户名) + */ +export async function validateProviderConfig( + provider: Provider, +): Promise { + try { + return await invoke('validate_provider_config', { provider }); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/lib/tauri-commands/types.ts b/src/lib/tauri-commands/types.ts index 2f30212..ca70bf5 100644 --- a/src/lib/tauri-commands/types.ts +++ b/src/lib/tauri-commands/types.ts @@ -3,6 +3,13 @@ import type { SSHConfig } from '@/types/tool-management'; import type { ProfileData, ProfileDescriptor, ProfilePayload, ToolId } from '@/types/profile'; +import type { + Provider, + ProviderStore, + ToolInstanceSelection, + _ProviderFormData, + ProviderValidationResult, +} from '@/types/provider'; // 重新导出 Profile 相关类型供其他模块使用 export type { ProfileData, ProfileDescriptor, ProfilePayload, ToolId }; @@ -10,6 +17,15 @@ export type { ProfileData, ProfileDescriptor, ProfilePayload, ToolId }; // 重新导出工具管理类型 export type { SSHConfig }; +// 重新导出供应商管理类型 +export type { + Provider, + ProviderStore, + ToolInstanceSelection, + _ProviderFormData, + ProviderValidationResult, +}; + export interface ToolStatus { mirrorIsStale: boolean; mirrorVersion: string | null; diff --git a/src/pages/DashboardPage/components/DashboardToolCard.tsx b/src/pages/DashboardPage/components/DashboardToolCard.tsx index 4d86080..1370f33 100644 --- a/src/pages/DashboardPage/components/DashboardToolCard.tsx +++ b/src/pages/DashboardPage/components/DashboardToolCard.tsx @@ -1,19 +1,29 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { CheckCircle2, RefreshCw, Loader2, Key } from 'lucide-react'; -import { logoMap, descriptionMap } from '@/utils/constants'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { RefreshCw, Loader2, Key } from 'lucide-react'; +import { logoMap } from '@/utils/constants'; import { formatVersionLabel } from '@/utils/formatting'; import type { ToolStatus } from '@/lib/tauri-commands'; +import type { ToolInstanceSelection } from '@/types/provider'; interface DashboardToolCardProps { tool: ToolStatus; updating: boolean; checking: boolean; // 当前工具是否正在检测更新 checkingAll: boolean; // 全局检测更新状态 + instanceSelection?: ToolInstanceSelection; onUpdate: () => void; onCheckUpdates: () => void; onConfigure: () => void; + onInstanceChange: (instanceType: string) => void; } export function DashboardToolCard({ @@ -21,132 +31,151 @@ export function DashboardToolCard({ updating, checking, checkingAll, + instanceSelection, onUpdate, onCheckUpdates, onConfigure, + onInstanceChange, }: DashboardToolCardProps) { // 是否正在检测更新(全局或单工具) const isChecking = checking || checkingAll; // 已检测完成且是最新版(确保只在检测更新后才显示) const isLatest = tool.hasUpdate === false && Boolean(tool.latestVersion); + // 实例类型选项 + const instanceOptions = [ + { value: 'local', label: '本地环境 (Local)' }, + { value: 'wsl', label: 'WSL 环境' }, + { value: 'ssh', label: 'SSH 远程' }, + ]; + + const currentInstanceType = instanceSelection?.instance_type || 'local'; + return ( -
-
-
- {tool.name} -
-
-
-

{tool.name}

- +
+
+ {tool.name} +
+
+
+

{tool.name}

+ {tool.hasUpdate && ( + + + 有更新 + + )} + {isLatest && ( + - 已安装 + 最新版 - {tool.hasUpdate && ( - - - 有更新 - - )} - {isLatest && ( - - - 最新版 - - )} -
-

- {descriptionMap[tool.id]} -

-
-
- - 当前版本: - - - {formatVersionLabel(tool.version)} - -
- {tool.hasUpdate && tool.latestVersion && ( -
- - 最新版本: - - - {formatVersionLabel(tool.latestVersion)} - -
- )} - {isLatest && tool.latestVersion && ( -
- - 最新版本: - - - {formatVersionLabel(tool.latestVersion)} - -
- )} -
+ )}
-
-
- + {/* 实例选择下拉框 */} +
+ +
+
+
- {tool.hasUpdate ? ( - - ) : ( - - )} +
+
+ + 当前版本: + + + {formatVersionLabel(tool.version)} +
+ {tool.hasUpdate && tool.latestVersion && ( +
+ + 最新版本: + + + {formatVersionLabel(tool.latestVersion)} + +
+ )} + {isLatest && tool.latestVersion && ( +
+ + 最新版本: + + + {formatVersionLabel(tool.latestVersion)} + +
+ )} +
+ +
+ + + {tool.hasUpdate ? ( + + ) : ( + + )}
diff --git a/src/pages/DashboardPage/components/ProviderTabs.tsx b/src/pages/DashboardPage/components/ProviderTabs.tsx new file mode 100644 index 0000000..16b4f4e --- /dev/null +++ b/src/pages/DashboardPage/components/ProviderTabs.tsx @@ -0,0 +1,138 @@ +// 供应商水平标签页组件 +// 用于在 Dashboard 中切换不同供应商 + +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Loader2, Building2, RefreshCw } from 'lucide-react'; +import type { Provider } from '@/lib/tauri-commands'; +import { QuotaCard } from '@/components/QuotaCard'; +import { TodayStatsCard } from '@/components/TodayStatsCard'; +import type { UserQuotaResult, UsageStatsResult } from '@/lib/tauri-commands/types'; + +interface ProviderTabsProps { + providers: Provider[]; + selectedProviderId: string | null; + loading: boolean; + quota: UserQuotaResult | null; + quotaLoading: boolean; + stats: UsageStatsResult | null; + statsLoading: boolean; + onProviderChange: (providerId: string) => void; + onRefresh?: () => void; // 新增刷新回调 +} + +export function ProviderTabs({ + providers, + selectedProviderId, + loading, + quota, + quotaLoading, + stats, + statsLoading, + onProviderChange, + onRefresh, +}: ProviderTabsProps) { + if (loading) { + return ( + + + + 加载供应商... + + + ); + } + + if (providers.length === 0) { + return ( + + +
+ + 暂无供应商 +
+
+ +

+ 请前往「全局设置 → 供应商管理」添加供应商配置 +

+
+
+ ); + } + + const currentProviderId = selectedProviderId || providers[0]?.id; + const isRefreshing = quotaLoading || statsLoading; + + return ( + + {/* 供应商标签列表和刷新按钮 */} +
+ + {providers.map((provider) => { + const isSelected = provider.id === currentProviderId; + return ( + +
+ {provider.name} + {provider.is_default && ( + + 默认 + + )} + {isSelected && ( + + 当前 + + )} +
+
+ ); + })} +
+ + {/* 刷新按钮 */} + {onRefresh && ( + + )} +
+ + {/* 供应商内容 */} + {providers.map((provider) => ( + +
+ {/* 额度卡片 */} + + + {/* 今日统计卡片 */} + +
+
+ ))} +
+ ); +} diff --git a/src/pages/DashboardPage/hooks/useDashboardProviders.ts b/src/pages/DashboardPage/hooks/useDashboardProviders.ts new file mode 100644 index 0000000..1a116c9 --- /dev/null +++ b/src/pages/DashboardPage/hooks/useDashboardProviders.ts @@ -0,0 +1,94 @@ +// Dashboard 供应商和实例选择管理 Hook + +import { useState, useEffect, useCallback } from 'react'; +import { + listProviders, + getToolInstanceSelection, + setToolInstanceSelection, + type Provider, + type ToolInstanceSelection, +} from '@/lib/tauri-commands'; + +export function useDashboardProviders() { + const [providers, setProviders] = useState([]); + const [loading, setLoading] = useState(false); + const [instanceSelections, setInstanceSelections] = useState< + Record + >({}); + + /** + * 加载所有供应商 + */ + const loadProviders = useCallback(async () => { + setLoading(true); + try { + const providerList = await listProviders(); + setProviders(providerList); + } catch (error) { + console.error('加载供应商失败:', error); + } finally { + setLoading(false); + } + }, []); + + /** + * 加载工具的实例选择 + */ + const loadInstanceSelection = useCallback(async (toolId: string) => { + try { + const selection = await getToolInstanceSelection(toolId); + if (selection) { + setInstanceSelections((prev) => ({ + ...prev, + [toolId]: selection, + })); + } + } catch (error) { + console.error(`加载工具 ${toolId} 实例选择失败:`, error); + } + }, []); + + /** + * 更新工具的实例选择 + */ + const handleSetInstanceSelection = useCallback(async (selection: ToolInstanceSelection) => { + try { + await setToolInstanceSelection(selection); + setInstanceSelections((prev) => ({ + ...prev, + [selection.tool_id]: selection, + })); + return { success: true }; + } catch (error) { + console.error('设置实例选择失败:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, []); + + /** + * 批量加载所有工具的实例选择 + */ + const loadAllInstanceSelections = useCallback(async () => { + const toolIds = ['claude-code', 'codex', 'gemini-cli']; + await Promise.all(toolIds.map((toolId) => loadInstanceSelection(toolId))); + }, [loadInstanceSelection]); + + /** + * 初始化加载 + */ + useEffect(() => { + loadProviders(); + loadAllInstanceSelections(); + }, [loadProviders, loadAllInstanceSelections]); + + return { + providers, + loading, + instanceSelections, + loadProviders, + setInstanceSelection: handleSetInstanceSelection, + }; +} diff --git a/src/pages/DashboardPage/index.tsx b/src/pages/DashboardPage/index.tsx index 5fbee7f..6dd677e 100644 --- a/src/pages/DashboardPage/index.tsx +++ b/src/pages/DashboardPage/index.tsx @@ -1,14 +1,22 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { RefreshCw, Loader2, Package, Search } from 'lucide-react'; import { PageContainer } from '@/components/layout/PageContainer'; import { DashboardToolCard } from './components/DashboardToolCard'; import { UpdateCheckBanner } from './components/UpdateCheckBanner'; +import { ProviderTabs } from './components/ProviderTabs'; import { useDashboard } from './hooks/useDashboard'; +import { useDashboardProviders } from './hooks/useDashboardProviders'; import { getToolDisplayName } from '@/utils/constants'; import { useToast } from '@/hooks/use-toast'; -import { refreshAllToolVersions, type ToolStatus } from '@/lib/tauri-commands'; +import { + getUserQuota, + refreshAllToolVersions, + getUsageStats, + type ToolStatus, +} from '@/lib/tauri-commands'; +import type { UserQuotaResult, UsageStatsResult } from '@/lib/tauri-commands/types'; interface DashboardPageProps { tools: ToolStatus[]; @@ -19,6 +27,10 @@ export function DashboardPage({ tools: toolsProp, loading: loadingProp }: Dashbo const { toast } = useToast(); const [loading, setLoading] = useState(loadingProp); const [refreshing, setRefreshing] = useState(false); + const [quota, setQuota] = useState(null); + const [quotaLoading, setQuotaLoading] = useState(false); + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); // 使用仪表板 Hook const { @@ -33,16 +45,65 @@ export function DashboardPage({ tools: toolsProp, loading: loadingProp }: Dashbo updateTools, } = useDashboard(toolsProp); + // 使用供应商管理 Hook + const { + providers, + loading: providersLoading, + instanceSelections, + setInstanceSelection, + } = useDashboardProviders(); + + // 选中的供应商 ID(纯前端状态) + const [selectedProviderId, setSelectedProviderId] = useState(null); + // 同步外部 tools 数据 useEffect(() => { updateTools(toolsProp); setLoading(loadingProp); }, [toolsProp, loadingProp, updateTools]); - // // 通知父组件刷新工具列表 - // const refreshTools = () => { - // window.dispatchEvent(new CustomEvent('refresh-tools')); - // }; + // 初始化时选中第一个供应商 + useEffect(() => { + if (providers.length > 0 && !selectedProviderId) { + setSelectedProviderId(providers[0].id); + } + }, [providers, selectedProviderId]); + + // 加载用户配额 + const loadQuota = useCallback(async (providerId: string) => { + setQuotaLoading(true); + try { + const quotaData = await getUserQuota(providerId); + setQuota(quotaData); + } catch (error) { + console.error('加载用户配额失败:', error); + setQuota(null); // 清空旧数据 + } finally { + setQuotaLoading(false); + } + }, []); + + // 加载用量统计 + const loadStats = useCallback(async (providerId: string) => { + setStatsLoading(true); + try { + const statsData = await getUsageStats(providerId); + setStats(statsData); + } catch (error) { + console.error('加载用量统计失败:', error); + setStats(null); // 清空旧数据 + } finally { + setStatsLoading(false); + } + }, []); + + // 加载用户配额和用量统计 + useEffect(() => { + if (selectedProviderId) { + loadQuota(selectedProviderId); + loadStats(selectedProviderId); + } + }, [selectedProviderId, loadQuota, loadStats]); // 手动刷新工具状态(刷新数据库版本号) const handleRefreshToolStatus = async () => { @@ -106,13 +167,46 @@ export function DashboardPage({ tools: toolsProp, loading: loadingProp }: Dashbo window.dispatchEvent(new CustomEvent('navigate-to-install')); }; + // 处理供应商切换(纯前端状态切换) + const handleProviderChange = (providerId: string) => { + setSelectedProviderId(providerId); + }; + + // 刷新当前供应商的配额和统计数据 + const handleRefreshProviderData = () => { + if (selectedProviderId) { + loadQuota(selectedProviderId); + loadStats(selectedProviderId); + } + }; + + // 处理实例选择变更 + const handleInstanceChange = async (toolId: string, instanceType: string) => { + const result = await setInstanceSelection({ + tool_id: toolId, + instance_type: instanceType, + }); + + if (result.success) { + toast({ + title: '实例已切换', + description: `${getToolDisplayName(toolId)} 已切换到${instanceType === 'local' ? '本地环境' : instanceType === 'wsl' ? 'WSL 环境' : 'SSH 远程'}`, + }); + } else { + toast({ + title: '切换失败', + description: result.error, + variant: 'destructive', + }); + } + }; + const installedTools = tools.filter((t) => t.installed); return (

仪表板

-

管理已安装的 AI 开发工具和配置

{loading ? ( @@ -145,65 +239,87 @@ export function DashboardPage({ tools: toolsProp, loading: loadingProp }: Dashbo ) : ( - <> - {/* 操作按钮 */} -
- - +
+ {/* 第一段:工具卡片 + 操作按钮 */} +
+
+ + +
+ + {/* 工具卡片列表 */} +
+ {installedTools.map((tool) => ( + onUpdate(tool.id)} + onCheckUpdates={() => checkSingleToolUpdate(tool.id)} + onConfigure={() => switchToConfig(tool.id)} + onInstanceChange={(instanceType) => + handleInstanceChange(tool.id, instanceType) + } + /> + ))} +
- {/* 工具卡片列表 */} -
- {installedTools.map((tool) => ( - onUpdate(tool.id)} - onCheckUpdates={() => checkSingleToolUpdate(tool.id)} - onConfigure={() => switchToConfig(tool.id)} - /> - ))} + {/* 第二段:供应商标签页 */} +
+

供应商与用量统计

+
- +
)} )} diff --git a/src/pages/ProviderManagementPage/components/DeleteConfirmDialog.tsx b/src/pages/ProviderManagementPage/components/DeleteConfirmDialog.tsx new file mode 100644 index 0000000..045b7d9 --- /dev/null +++ b/src/pages/ProviderManagementPage/components/DeleteConfirmDialog.tsx @@ -0,0 +1,55 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Loader2 } from 'lucide-react'; + +interface DeleteConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + providerName: string; + onConfirm: () => Promise; + deleting: boolean; +} + +export function DeleteConfirmDialog({ + open, + onOpenChange, + providerName, + onConfirm, + deleting, +}: DeleteConfirmDialogProps) { + return ( + + + + 确认删除供应商 + + 您确定要删除供应商 {providerName} 吗? +
+ 此操作无法撤销,所有相关配置将被永久删除。 +
+
+ + 取消 + + {deleting ? ( + <> + + 删除中... + + ) : ( + '确认删除' + )} + + +
+
+ ); +} diff --git a/src/pages/ProviderManagementPage/components/ProviderFormDialog.tsx b/src/pages/ProviderManagementPage/components/ProviderFormDialog.tsx new file mode 100644 index 0000000..f3d63a1 --- /dev/null +++ b/src/pages/ProviderManagementPage/components/ProviderFormDialog.tsx @@ -0,0 +1,248 @@ +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Loader2, CheckCircle2, XCircle, User } from 'lucide-react'; +import type { Provider } from '@/lib/tauri-commands'; +import { validateProviderConfig } from '@/lib/tauri-commands'; + +interface ProviderFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + provider: Provider | null; + onSubmit: (provider: Provider) => Promise; + isEditing: boolean; +} + +export function ProviderFormDialog({ + open, + onOpenChange, + provider, + onSubmit, + isEditing, +}: ProviderFormDialogProps) { + const [formData, setFormData] = useState({ + id: '', + name: '', + website_url: '', + user_id: '', + access_token: '', + is_default: false, + }); + const [saving, setSaving] = useState(false); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState<{ + success: boolean; + username?: string; + error?: string; + } | null>(null); + + useEffect(() => { + if (provider) { + setFormData({ + id: provider.id, + name: provider.name, + website_url: provider.website_url, + user_id: provider.user_id, + access_token: provider.access_token, + is_default: provider.is_default, + }); + } else { + setFormData({ + id: '', + name: '', + website_url: 'https://duckcoding.com', + user_id: '', + access_token: '', + is_default: false, + }); + } + setValidationResult(null); + }, [provider, open]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + const now = Math.floor(Date.now() / 1000); + // 创建新供应商时自动生成 ID(基于名称的小写字母 + 时间戳) + const providerId = isEditing + ? formData.id + : `${formData.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}`; + + const providerData: Provider = { + ...formData, + id: providerId, + username: provider?.username || validationResult?.username, + created_at: provider?.created_at || now, + updated_at: now, + }; + await onSubmit(providerData); + onOpenChange(false); + } catch (error) { + console.error('保存供应商失败:', error); + } finally { + setSaving(false); + } + }; + + const handleValidate = async () => { + setValidating(true); + setValidationResult(null); + try { + const now = Math.floor(Date.now() / 1000); + const testProvider: Provider = { + ...formData, + created_at: now, + updated_at: now, + }; + + const result = await validateProviderConfig(testProvider); + setValidationResult(result); + } catch (error) { + setValidationResult({ + success: false, + error: String(error), + }); + } finally { + setValidating(false); + } + }; + + return ( + + + + {isEditing ? '编辑供应商' : '添加供应商'} + + {isEditing ? '修改供应商配置信息' : '配置新的 AI 服务供应商'} + + + +
+
+ {/* 供应商名称 */} +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="例如: DuckCoding" + required + /> +
+ + {/* 官网地址 */} +
+ + setFormData({ ...formData, website_url: e.target.value })} + placeholder="https://duckcoding.com" + required + /> +
+ + {/* 用户 ID */} +
+ + setFormData({ ...formData, user_id: e.target.value })} + placeholder="您的用户 ID" + required + /> +
+ + {/* 访问令牌 */} +
+ + setFormData({ ...formData, access_token: e.target.value })} + placeholder="您的访问令牌" + required + /> +
+ + {/* 验证结果 */} + {validationResult && ( +
+ {validationResult.success ? ( + + ) : ( + + )} +
+ {validationResult.success ? ( + <> +

配置验证通过

+ {validationResult.username && ( +
+ + + 用户名: {validationResult.username} + +
+ )} + + ) : ( + <> +

验证失败

+

{validationResult.error}

+ + )} +
+
+ )} +
+ + + + + +
+
+
+ ); +} diff --git a/src/pages/ProviderManagementPage/hooks/useProviderManagement.ts b/src/pages/ProviderManagementPage/hooks/useProviderManagement.ts new file mode 100644 index 0000000..b585b75 --- /dev/null +++ b/src/pages/ProviderManagementPage/hooks/useProviderManagement.ts @@ -0,0 +1,93 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + listProviders, + createProvider, + updateProvider, + deleteProvider, + type Provider, +} from '@/lib/tauri-commands'; + +export function useProviderManagement() { + const [providers, setProviders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 加载供应商列表 + const loadProviders = useCallback(async () => { + setLoading(true); + setError(null); + try { + const providerList = await listProviders(); + setProviders(providerList); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + setError(errorMsg); + console.error('加载供应商失败:', err); + } finally { + setLoading(false); + } + }, []); + + // 创建供应商 + const handleCreate = useCallback( + async (provider: Provider) => { + try { + await createProvider(provider); + await loadProviders(); + return { success: true }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('创建供应商失败:', err); + return { success: false, error: errorMsg }; + } + }, + [loadProviders], + ); + + // 更新供应商 + const handleUpdate = useCallback( + async (id: string, provider: Provider) => { + try { + await updateProvider(id, provider); + await loadProviders(); + return { success: true }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('更新供应商失败:', err); + return { success: false, error: errorMsg }; + } + }, + [loadProviders], + ); + + // 删除供应商 + const handleDelete = useCallback( + async (id: string) => { + try { + await deleteProvider(id); + await loadProviders(); + return { success: true }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('删除供应商失败:', err); + return { success: false, error: errorMsg }; + } + }, + [loadProviders], + ); + + // 初始化加载 + useEffect(() => { + loadProviders(); + }, [loadProviders]); + + return { + providers, + loading, + error, + loadProviders, + createProvider: handleCreate, + updateProvider: handleUpdate, + deleteProvider: handleDelete, + }; +} diff --git a/src/pages/ProviderManagementPage/index.tsx b/src/pages/ProviderManagementPage/index.tsx new file mode 100644 index 0000000..4338e7c --- /dev/null +++ b/src/pages/ProviderManagementPage/index.tsx @@ -0,0 +1,230 @@ +import { PageContainer } from '@/components/layout/PageContainer'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Building2, Plus, Pencil, Trash2, Loader2 } from 'lucide-react'; +import { useState } from 'react'; +import type { Provider } from '@/lib/tauri-commands'; +import { useToast } from '@/hooks/use-toast'; +import { useProviderManagement } from './hooks/useProviderManagement'; +import { ProviderFormDialog } from './components/ProviderFormDialog'; +import { DeleteConfirmDialog } from './components/DeleteConfirmDialog'; + +/** + * 供应商管理页面 + * 独立的顶级页面,用于管理所有 AI 服务供应商 + */ +export function ProviderManagementPage() { + const { toast } = useToast(); + const { providers, loading, error, createProvider, updateProvider, deleteProvider } = + useProviderManagement(); + + const [formDialogOpen, setFormDialogOpen] = useState(false); + const [editingProvider, setEditingProvider] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletingProvider, setDeletingProvider] = useState(null); + const [deleting, setDeleting] = useState(false); + + /** + * 打开新增对话框 + */ + const handleAdd = () => { + setEditingProvider(null); + setFormDialogOpen(true); + }; + + /** + * 打开编辑对话框 + */ + const handleEdit = (provider: Provider) => { + setEditingProvider(provider); + setFormDialogOpen(true); + }; + + /** + * 提交表单(创建或更新) + */ + const handleFormSubmit = async (provider: Provider) => { + const result = editingProvider + ? await updateProvider(editingProvider.id, provider) + : await createProvider(provider); + + if (result.success) { + toast({ + title: editingProvider ? '供应商已更新' : '供应商已创建', + description: `供应商「${provider.name}」已成功${editingProvider ? '更新' : '创建'}`, + }); + setFormDialogOpen(false); + } else { + toast({ + title: editingProvider ? '更新失败' : '创建失败', + description: result.error, + variant: 'destructive', + }); + } + }; + + /** + * 删除供应商 + */ + const handleDelete = async (id: string) => { + setDeleting(true); + const result = await deleteProvider(id); + + if (result.success) { + toast({ + title: '供应商已删除', + description: '供应商已成功删除', + }); + setDeleteDialogOpen(false); + setDeletingProvider(null); + } else { + toast({ + title: '删除失败', + description: result.error, + variant: 'destructive', + }); + } + setDeleting(false); + }; + + /** + * 格式化时间戳 + */ + const formatTimestamp = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleString('zh-CN'); + }; + + return ( + +
+ {/* 顶部标题栏 */} +
+
+ +

供应商管理

+
+ +
+ + + {/* 错误提示 */} + {error && ( +
+

加载失败: {error}

+
+ )} + + {/* 加载状态 */} + {loading ? ( +
+ + 加载中... +
+ ) : providers.length === 0 ? ( +
+ +

暂无供应商,请点击「新增供应商」按钮添加

+
+ ) : ( +
+ + + + 名称 + 官网地址 + 用户名 + 更新时间 + 操作 + + + + {providers.map((provider) => { + return ( + + {/* 名称 */} + + {provider.name} + {provider.is_default && ( + (默认) + )} + + + {/* 官网地址 */} + + + {provider.website_url} + + + + {/* 用户名 */} + {provider.username || '-'} + + {/* 更新时间 */} + + {formatTimestamp(provider.updated_at)} + + + {/* 操作 */} + +
+ + +
+
+
+ ); + })} +
+
+
+ )} +
+ + {/* 表单对话框 */} + + + {/* 删除确认对话框 */} + deletingProvider && handleDelete(deletingProvider.id)} + deleting={deleting} + /> +
+ ); +} diff --git a/src/types/provider.ts b/src/types/provider.ts new file mode 100644 index 0000000..eae6153 --- /dev/null +++ b/src/types/provider.ts @@ -0,0 +1,83 @@ +/** + * 供应商管理系统类型定义 + */ + +/** + * 供应商信息 + */ +export interface Provider { + /** 供应商唯一标识(如 "duckcoding") */ + id: string; + /** 供应商名称(用于显示) */ + name: string; + /** 供应商官网地址 */ + website_url: string; + /** 用户ID */ + user_id: string; + /** 访问令牌 */ + access_token: string; + /** 用户名(可选) */ + username?: string; + /** 是否为默认供应商 */ + is_default: boolean; + /** 创建时间(Unix timestamp) */ + created_at: number; + /** 更新时间(Unix timestamp) */ + updated_at: number; +} + +/** + * 工具实例选择 + */ +export interface ToolInstanceSelection { + /** 工具ID("claude-code" | "codex" | "gemini-cli") */ + tool_id: string; + /** 实例类型("local" | "wsl" | "ssh") */ + instance_type: string; + /** 实例路径(SSH 类型必填) */ + instance_path?: string; +} + +/** + * 供应商存储结构 + */ +export interface ProviderStore { + /** 数据版本 */ + version: number; + /** 供应商列表 */ + providers: Provider[]; + /** 当前激活的供应商ID */ + active_provider_id?: string; + /** 工具实例选择映射(key: tool_id, value: selection) */ + tool_instances: Record; + /** 最后更新时间(Unix timestamp) */ + updated_at: number; +} + +/** + * 供应商配置表单数据(暂未使用,保留给 UI 组件) + */ +export interface _ProviderFormData { + /** 供应商名称 */ + name: string; + /** 官网地址 */ + website_url: string; + /** 用户ID */ + user_id: string; + /** 访问令牌 */ + access_token: string; + /** 用户名(可选) */ + username?: string; +} + +/** + * 供应商验证结果 + */ +export interface ProviderValidationResult { + /** 是否验证成功 */ + success: boolean; + /** 从 API 获取的用户名(用于确认身份) */ + username?: string; + /** 错误消息(验证失败时) */ + error?: string; +} From 263252f40fd82ffd23550c52687de65e4ca6ca84 Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Sat, 3 Jan 2026 09:41:05 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8=E5=88=87=E6=8D=A2=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=20Dashboard=20=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题修复: - 实例选择器选择后立即回退的问题 - 切换实例后版本信息未更新的问题 核心改动: 1. 数据结构重构(instance_type → instance_id) - 后端:ToolInstanceSelection 改为存储唯一 instance_id - 前端:直接使用 instance_id 匹配,消除类型模糊查找 - 支持同类型多实例的精确识别 2. 实例版本动态显示 - DashboardToolCard 接收 toolInstances 参数 - 根据 instanceSelection.instance_id 查找当前实例 - 优先显示实例真实版本而非工具统一版本 3. Dashboard UI 重构 - 固定显示 3 个工具(claude-code/codex/gemini-cli) - 未安装工具显示占位卡片 + "前往安装"按钮 - 实例选择器仅已安装工具显示 - 添加"工具状态"和"供应商与用量统计"分段标题 4. 导航增强 - 新增 navigate-to-config 事件支持 - 工具配置按钮跳转到工具管理页 5. 供应商管理优化 - 移除 ProviderTabs 的"默认"标签(避免混淆) - ProviderFormDialog 添加获取凭证的帮助提示 技术细节: - 遵循 DRY 原则:统一 instance_id 查找逻辑 - 类型安全:补充占位对象 mirrorIsStale/mirrorVersion 字段 - 架构清晰:保持三层架构(Commands → Services → Utils) 测试:所有代码检查通过(ESLint + Clippy + Prettier + fmt) --- src-tauri/src/commands/provider_commands.rs | 15 +- src-tauri/src/models/provider.rs | 12 +- src/App.tsx | 4 + src/hooks/useAppEvents.ts | 17 +- .../components/DashboardToolCard.tsx | 179 +++++++++++------- .../DashboardPage/components/ProviderTabs.tsx | 5 - .../hooks/useDashboardProviders.ts | 48 ++++- src/pages/DashboardPage/index.tsx | 169 ++++++++++------- .../components/ProviderFormDialog.tsx | 27 ++- src/pages/ProviderManagementPage/index.tsx | 13 +- src/types/provider.ts | 6 +- 11 files changed, 321 insertions(+), 174 deletions(-) diff --git a/src-tauri/src/commands/provider_commands.rs b/src-tauri/src/commands/provider_commands.rs index c210497..908a8d4 100644 --- a/src-tauri/src/commands/provider_commands.rs +++ b/src-tauri/src/commands/provider_commands.rs @@ -123,19 +123,8 @@ pub async fn set_tool_instance_selection( if selection.tool_id.is_empty() { return Err("工具 ID 不能为空".to_string()); } - if selection.instance_type.is_empty() { - return Err("实例类型不能为空".to_string()); - } - - // 验证实例类型 - match selection.instance_type.as_str() { - "local" | "wsl" | "ssh" => {} - _ => return Err("无效的实例类型,必须是 local、wsl 或 ssh".to_string()), - } - - // SSH 实例必须提供路径 - if selection.instance_type == "ssh" && selection.instance_path.is_none() { - return Err("SSH 实例必须提供实例路径".to_string()); + if selection.instance_id.is_empty() { + return Err("实例 ID 不能为空".to_string()); } state diff --git a/src-tauri/src/models/provider.rs b/src-tauri/src/models/provider.rs index 2beac36..f791794 100644 --- a/src-tauri/src/models/provider.rs +++ b/src-tauri/src/models/provider.rs @@ -33,10 +33,8 @@ pub struct Provider { pub struct ToolInstanceSelection { /// 工具ID ("claude-code" | "codex" | "gemini-cli") pub tool_id: String, - /// 实例类型 ("local" | "wsl" | "ssh") - pub instance_type: String, - /// SSH 实例的路径(可选) - pub instance_path: Option, + /// 实例唯一ID(如 "claude-code-local", "codex-wsl-Ubuntu") + pub instance_id: String, } /// 供应商存储结构 @@ -115,15 +113,13 @@ mod tests { fn test_tool_instance_selection() { let selection = ToolInstanceSelection { tool_id: "claude-code".to_string(), - instance_type: "local".to_string(), - instance_path: None, + instance_id: "claude-code-local".to_string(), }; let json = serde_json::to_string(&selection).unwrap(); let deserialized: ToolInstanceSelection = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.tool_id, "claude-code"); - assert_eq!(deserialized.instance_type, "local"); - assert!(deserialized.instance_path.is_none()); + assert_eq!(deserialized.instance_id, "claude-code-local"); } } diff --git a/src/App.tsx b/src/App.tsx index 5bd5cd0..cbee7ae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -311,6 +311,10 @@ function App() { }); }, onNavigateToInstall: () => setActiveTab('install'), + onNavigateToConfig: (_detail) => { + setActiveTab('tool-management'); + // TODO: 如果需要滚动到指定工具,可以通过 _detail.toolId 实现 + }, onNavigateToSettings: (detail) => { setSettingsInitialTab(detail?.tab ?? 'basic'); setActiveTab('settings'); diff --git a/src/hooks/useAppEvents.ts b/src/hooks/useAppEvents.ts index 6062e7c..651eb7d 100644 --- a/src/hooks/useAppEvents.ts +++ b/src/hooks/useAppEvents.ts @@ -28,6 +28,7 @@ interface AppEventsOptions { onCloseRequest: () => void; onSingleInstance: (message: string) => void; onNavigateToInstall: () => void; + onNavigateToConfig: (detail?: { toolId?: string }) => void; onNavigateToSettings: (detail?: { tab?: string }) => void; onNavigateToTransparentProxy: (detail?: { toolId?: string }) => void; onRefreshTools: () => void; @@ -39,6 +40,7 @@ export function useAppEvents(options: AppEventsOptions) { onCloseRequest, onSingleInstance, onNavigateToInstall, + onNavigateToConfig, onNavigateToSettings, onNavigateToTransparentProxy, onRefreshTools, @@ -128,6 +130,11 @@ export function useAppEvents(options: AppEventsOptions) { // 监听页面导航事件 useEffect(() => { + const handleNavigateToConfig = (event: Event) => { + const customEvent = event as CustomEvent<{ toolId?: string }>; + onNavigateToConfig(customEvent.detail); + }; + const handleNavigateToTransparentProxy = (event: Event) => { const customEvent = event as CustomEvent<{ toolId?: string }>; onNavigateToTransparentProxy(customEvent.detail); @@ -139,15 +146,23 @@ export function useAppEvents(options: AppEventsOptions) { }; window.addEventListener('navigate-to-install', onNavigateToInstall); + window.addEventListener('navigate-to-config', handleNavigateToConfig); window.addEventListener('navigate-to-settings', handleNavigateToSettings); window.addEventListener('navigate-to-transparent-proxy', handleNavigateToTransparentProxy); window.addEventListener('refresh-tools', onRefreshTools); return () => { window.removeEventListener('navigate-to-install', onNavigateToInstall); + window.removeEventListener('navigate-to-config', handleNavigateToConfig); window.removeEventListener('navigate-to-settings', handleNavigateToSettings); window.removeEventListener('navigate-to-transparent-proxy', handleNavigateToTransparentProxy); window.removeEventListener('refresh-tools', onRefreshTools); }; - }, [onNavigateToInstall, onNavigateToSettings, onNavigateToTransparentProxy, onRefreshTools]); + }, [ + onNavigateToInstall, + onNavigateToConfig, + onNavigateToSettings, + onNavigateToTransparentProxy, + onRefreshTools, + ]); } diff --git a/src/pages/DashboardPage/components/DashboardToolCard.tsx b/src/pages/DashboardPage/components/DashboardToolCard.tsx index 1370f33..2be7367 100644 --- a/src/pages/DashboardPage/components/DashboardToolCard.tsx +++ b/src/pages/DashboardPage/components/DashboardToolCard.tsx @@ -8,11 +8,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { RefreshCw, Loader2, Key } from 'lucide-react'; +import { RefreshCw, Loader2, Key, Monitor, CheckCircle2, Package } from 'lucide-react'; import { logoMap } from '@/utils/constants'; import { formatVersionLabel } from '@/utils/formatting'; import type { ToolStatus } from '@/lib/tauri-commands'; import type { ToolInstanceSelection } from '@/types/provider'; +import type { ToolInstance } from '@/types/tool-management'; interface DashboardToolCardProps { tool: ToolStatus; @@ -20,10 +21,13 @@ interface DashboardToolCardProps { checking: boolean; // 当前工具是否正在检测更新 checkingAll: boolean; // 全局检测更新状态 instanceSelection?: ToolInstanceSelection; + instanceOptions: Array<{ value: string; label: string }>; // 实例选项列表 + toolInstances: ToolInstance[]; // 工具实例数据 onUpdate: () => void; onCheckUpdates: () => void; onConfigure: () => void; onInstanceChange: (instanceType: string) => void; + onInstall: () => void; // 新增:前往安装页面 } export function DashboardToolCard({ @@ -32,111 +36,150 @@ export function DashboardToolCard({ checking, checkingAll, instanceSelection, + instanceOptions, + toolInstances, onUpdate, onCheckUpdates, onConfigure, onInstanceChange, + onInstall, }: DashboardToolCardProps) { // 是否正在检测更新(全局或单工具) const isChecking = checking || checkingAll; // 已检测完成且是最新版(确保只在检测更新后才显示) const isLatest = tool.hasUpdate === false && Boolean(tool.latestVersion); - // 实例类型选项 - const instanceOptions = [ - { value: 'local', label: '本地环境 (Local)' }, - { value: 'wsl', label: 'WSL 环境' }, - { value: 'ssh', label: 'SSH 远程' }, - ]; + // 直接使用保存的 instance_id,如果不存在则使用第一个选项 + const currentInstanceId = instanceSelection?.instance_id || instanceOptions[0]?.value || ''; - const currentInstanceType = instanceSelection?.instance_type || 'local'; + // 从 toolInstances 中找到当前选中的实例 + const currentInstance = toolInstances.find((inst) => inst.instance_id === currentInstanceId); + + // 显示版本:优先使用当前实例的版本,其次使用 tool.version(兼容旧逻辑) + const displayVersion = currentInstance?.version || tool.version; return ( -
-
- {tool.name} -
-
-
-

{tool.name}

- {tool.hasUpdate && ( - - - 有更新 - - )} - {isLatest && ( - - - 最新版 - - )} +
+ {/* 第一行:图标 + 标题 + Badges */} +
+
+ {tool.name}
+

{tool.name}

+ {tool.hasUpdate && ( + + + 有更新 + + )} + {isLatest && ( + + + 最新版 + + )} +
- {/* 实例选择下拉框 */} -
- + + - {instanceOptions.map((option) => ( - - {option.label} - - ))} + {instanceOptions.length === 0 ? ( +
暂无配置的实例
+ ) : ( + instanceOptions.map((option) => ( + + {option.label} + + )) + )}
-
+ )}
-
- - 当前版本: - - - {formatVersionLabel(tool.version)} - -
- {tool.hasUpdate && tool.latestVersion && ( -
- - 最新版本: - - - {formatVersionLabel(tool.latestVersion)} - -
- )} - {isLatest && tool.latestVersion && ( + {tool.installed ? ( + <> +
+ + 当前版本: + + + {formatVersionLabel(displayVersion)} + +
+ {tool.hasUpdate && tool.latestVersion && ( +
+ + 最新版本: + + + {formatVersionLabel(tool.latestVersion)} + +
+ )} + {isLatest && tool.latestVersion && ( +
+ + 最新版本: + + + {formatVersionLabel(tool.latestVersion)} + +
+ )} + + ) : (
- 最新版本: + 状态: - - {formatVersionLabel(tool.latestVersion)} + + 未安装
)}
- - {tool.hasUpdate ? ( + {!tool.installed ? ( + + ) : tool.hasUpdate ? ( -
- - - ) : ( -
- {/* 第一段:工具卡片 + 操作按钮 */} -
-
+
+ {/* 第一段:工具卡片 + 操作按钮 */} +
+
+

工具状态

+
+
- {/* 工具卡片列表 */} -
- {installedTools.map((tool) => ( - onUpdate(tool.id)} - onCheckUpdates={() => checkSingleToolUpdate(tool.id)} - onConfigure={() => switchToConfig(tool.id)} - onInstanceChange={(instanceType) => - handleInstanceChange(tool.id, instanceType) - } - /> - ))} -
+ {/* 工具卡片列表 */} +
+ {displayTools.map((tool) => ( + onUpdate(tool.id)} + onCheckUpdates={() => checkSingleToolUpdate(tool.id)} + onConfigure={() => switchToConfig(tool.id)} + onInstanceChange={(instanceId) => handleInstanceChange(tool.id, instanceId)} + onInstall={switchToInstall} + /> + ))}
+
- {/* 第二段:供应商标签页 */} -
-

供应商与用量统计

- + {/* 第二段:供应商标签页 */} +
+
+

供应商与用量统计

+
+
- )} +
)} diff --git a/src/pages/ProviderManagementPage/components/ProviderFormDialog.tsx b/src/pages/ProviderManagementPage/components/ProviderFormDialog.tsx index f3d63a1..e3959e3 100644 --- a/src/pages/ProviderManagementPage/components/ProviderFormDialog.tsx +++ b/src/pages/ProviderManagementPage/components/ProviderFormDialog.tsx @@ -10,9 +10,10 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Loader2, CheckCircle2, XCircle, User } from 'lucide-react'; +import { Loader2, CheckCircle2, XCircle, User, Info } from 'lucide-react'; import type { Provider } from '@/lib/tauri-commands'; import { validateProviderConfig } from '@/lib/tauri-commands'; +import { openExternalLink } from '@/utils/formatting.ts'; interface ProviderFormDialogProps { open: boolean; @@ -179,6 +180,30 @@ export function ProviderFormDialog({ />
+
+
+ +
+

+ 如何获取用户 ID 和系统访问令牌? +

+

+ 请访问 对应平台的[控制台-{'>'}个人设置-{'>'}安全设置-{'>'}系统访问令牌] + 获取您的凭证信息 +

+

+ DuckCoding用户? +

+ +
+
+
+ {/* 验证结果 */} {validationResult && (
{/* 名称 */} - - {provider.name} - {provider.is_default && ( - (默认) - )} - + {provider.name} {/* 官网地址 */} @@ -222,7 +217,11 @@ export function ProviderManagementPage() { open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen} providerName={deletingProvider?.name || ''} - onConfirm={() => deletingProvider && handleDelete(deletingProvider.id)} + onConfirm={async () => { + if (deletingProvider) { + await handleDelete(deletingProvider.id); + } + }} deleting={deleting} /> diff --git a/src/types/provider.ts b/src/types/provider.ts index eae6153..72fec0a 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -32,10 +32,8 @@ export interface Provider { export interface ToolInstanceSelection { /** 工具ID("claude-code" | "codex" | "gemini-cli") */ tool_id: string; - /** 实例类型("local" | "wsl" | "ssh") */ - instance_type: string; - /** 实例路径(SSH 类型必填) */ - instance_path?: string; + /** 实例唯一ID(如 "claude-code-local", "codex-wsl-Ubuntu") */ + instance_id: string; } /** From e5b22b96074e96fc91a486f41a0ff0eeac1b6992 Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Sat, 3 Jan 2026 10:45:35 +0800 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20=E5=B0=86=20Dashboard=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E4=BB=8E=20providers.json?= =?UTF-8?q?=20=E5=88=86=E7=A6=BB=E5=88=B0=E7=8B=AC=E7=AB=8B=E7=9A=84=20das?= =?UTF-8?q?hboard.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 架构变更 ### 后端(Rust) - 新增 `models/dashboard.rs`:DashboardStore 数据模型 - `tool_instance_selections: HashMap` - 工具实例选择 - `selected_provider_id: Option` - 选中的供应商 ID - 新增 `services/dashboard_manager.rs`:DashboardManager 服务 - 提供实例选择和供应商状态的 CRUD 接口 - 实现缓存机制,持久化到 `~/.duckcoding/dashboard.json` - 新增 `commands/dashboard_commands.rs`:4 个 Tauri 命令 - get/set_tool_instance_selection - get/set_selected_provider_id - 更新 `main.rs`:注册 DashboardManagerState 和命令 - 清理 `ProviderStore` 和 `ProviderManager` 中的实例选择逻辑 ### 前端(TypeScript/React) - 新增 `lib/tauri-commands/dashboard.ts`:Dashboard API 包装器 - 重构 `useDashboardProviders` Hook - 数据简化:从 `Record` 改为 `Record` - 函数签名简化:`setInstanceSelection(toolId, instanceId)` 替代传递对象 - 修复 `DashboardToolCard.tsx` 类型不匹配 bug - 删除已废弃的 `ToolInstanceSelection` 类型引用 - 修改 prop 和读取逻辑以使用字符串类型 - 新增 `DashboardPage/index.tsx` 供应商 Tab 持久化 - 初始化时从后端加载 `selectedProviderId` - 切换时自动保存到后端 - 删除 `types/provider.ts` 中的 `ToolInstanceSelection` 接口 ## 设计原则 - 职责分离:Dashboard 状态独立于 Provider 数据 - 数据简化:直接存储 instance_id 字符串,无需对象包装 - 架构清晰:每个模块职责明确,修改影响范围可控 ## 质量保证 - ✅ 所有 ESLint/Clippy/Prettier/fmt 检查通过 - ✅ 遵循 SOLID、KISS、DRY、YAGNI 原则 --- src-tauri/src/commands/dashboard_commands.rs | 86 +++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/provider_commands.rs | 38 +--- src-tauri/src/main.rs | 8 +- src-tauri/src/models/dashboard.rs | 74 +++++++ src-tauri/src/models/mod.rs | 2 + src-tauri/src/models/provider.rs | 28 --- src-tauri/src/services/dashboard_manager.rs | 181 ++++++++++++++++++ src-tauri/src/services/mod.rs | 2 + src-tauri/src/services/provider_manager.rs | 20 +- src/App.tsx | 3 +- src/hooks/useAppEvents.ts | 4 + src/lib/tauri-commands/dashboard.ts | 38 ++++ src/lib/tauri-commands/index.ts | 3 + src/lib/tauri-commands/provider.ts | 25 +-- src/lib/tauri-commands/types.ts | 9 +- .../components/DashboardToolCard.tsx | 41 ++-- .../hooks/useDashboardProviders.ts | 22 +-- src/pages/DashboardPage/index.tsx | 56 ++++-- src/types/provider.ts | 12 -- 20 files changed, 482 insertions(+), 172 deletions(-) create mode 100644 src-tauri/src/commands/dashboard_commands.rs create mode 100644 src-tauri/src/models/dashboard.rs create mode 100644 src-tauri/src/services/dashboard_manager.rs create mode 100644 src/lib/tauri-commands/dashboard.ts diff --git a/src-tauri/src/commands/dashboard_commands.rs b/src-tauri/src/commands/dashboard_commands.rs new file mode 100644 index 0000000..c3a12e5 --- /dev/null +++ b/src-tauri/src/commands/dashboard_commands.rs @@ -0,0 +1,86 @@ +// Dashboard Commands +// +// 仪表板状态管理 Tauri 命令 + +use ::duckcoding::services::DashboardManager; +use anyhow::Result; +use tauri::State; + +/// Dashboard 管理器 State +pub struct DashboardManagerState { + pub manager: DashboardManager, +} + +impl DashboardManagerState { + pub fn new() -> Self { + Self { + manager: DashboardManager::new().expect("Failed to create DashboardManager"), + } + } +} + +impl Default for DashboardManagerState { + fn default() -> Self { + Self::new() + } +} + +/// 获取工具实例选择 +#[tauri::command] +pub async fn get_tool_instance_selection( + tool_id: String, + state: State<'_, DashboardManagerState>, +) -> Result, String> { + if tool_id.is_empty() { + return Err("工具 ID 不能为空".to_string()); + } + + state + .manager + .get_tool_instance_selection(&tool_id) + .map_err(|e| format!("获取工具实例选择失败: {}", e)) +} + +/// 设置工具实例选择 +#[tauri::command] +pub async fn set_tool_instance_selection( + tool_id: String, + instance_id: String, + state: State<'_, DashboardManagerState>, +) -> Result<(), String> { + // 验证参数 + if tool_id.is_empty() { + return Err("工具 ID 不能为空".to_string()); + } + if instance_id.is_empty() { + return Err("实例 ID 不能为空".to_string()); + } + + state + .manager + .set_tool_instance_selection(tool_id, instance_id) + .map_err(|e| format!("设置工具实例选择失败: {}", e)) +} + +/// 获取最后选中的供应商 ID +#[tauri::command] +pub async fn get_selected_provider_id( + state: State<'_, DashboardManagerState>, +) -> Result, String> { + state + .manager + .get_selected_provider_id() + .map_err(|e| format!("获取选中供应商失败: {}", e)) +} + +/// 设置最后选中的供应商 ID +#[tauri::command] +pub async fn set_selected_provider_id( + provider_id: Option, + state: State<'_, DashboardManagerState>, +) -> Result<(), String> { + state + .manager + .set_selected_provider_id(provider_id) + .map_err(|e| format!("设置选中供应商失败: {}", e)) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 24de824..434e111 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod balance_commands; pub mod config_commands; +pub mod dashboard_commands; // 仪表板状态管理命令 pub mod error; // 错误处理统一模块 pub mod log_commands; pub mod onboarding; @@ -19,6 +20,7 @@ pub mod window_commands; // 重新导出所有命令函数 pub use balance_commands::*; pub use config_commands::*; +pub use dashboard_commands::*; // 仪表板状态管理命令 pub use log_commands::*; pub use onboarding::*; pub use profile_commands::*; // Profile 管理命令(v2.0) diff --git a/src-tauri/src/commands/provider_commands.rs b/src-tauri/src/commands/provider_commands.rs index 908a8d4..5dc656d 100644 --- a/src-tauri/src/commands/provider_commands.rs +++ b/src-tauri/src/commands/provider_commands.rs @@ -2,7 +2,7 @@ // // 供应商管理 Tauri 命令 -use ::duckcoding::models::provider::{Provider, ToolInstanceSelection}; +use ::duckcoding::models::provider::Provider; use ::duckcoding::services::ProviderManager; use anyhow::Result; use tauri::State; @@ -97,42 +97,6 @@ pub async fn delete_provider( .map_err(|e| format!("删除供应商失败: {}", e)) } -/// 获取工具实例选择 -#[tauri::command] -pub async fn get_tool_instance_selection( - tool_id: String, - state: State<'_, ProviderManagerState>, -) -> Result, String> { - if tool_id.is_empty() { - return Err("工具 ID 不能为空".to_string()); - } - - state - .manager - .get_tool_instance(&tool_id) - .map_err(|e| format!("获取工具实例选择失败: {}", e)) -} - -/// 设置工具实例选择 -#[tauri::command] -pub async fn set_tool_instance_selection( - selection: ToolInstanceSelection, - state: State<'_, ProviderManagerState>, -) -> Result<(), String> { - // 验证参数 - if selection.tool_id.is_empty() { - return Err("工具 ID 不能为空".to_string()); - } - if selection.instance_id.is_empty() { - return Err("实例 ID 不能为空".to_string()); - } - - state - .manager - .set_tool_instance(selection) - .map_err(|e| format!("设置工具实例选择失败: {}", e)) -} - /// 验证结果结构 #[derive(serde::Serialize)] pub struct ValidationResult { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 376670a..569449b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -201,6 +201,8 @@ fn main() { let provider_manager_state = ProviderManagerState::new(); + let dashboard_manager_state = DashboardManagerState::new(); + // 判断单实例模式 let single_instance_enabled = determine_single_instance_mode(); @@ -217,6 +219,7 @@ fn main() { .manage(tool_registry_state) .manage(profile_manager_state) .manage(provider_manager_state) + .manage(dashboard_manager_state) .setup(|app| { setup_app_hooks(app)?; Ok(()) @@ -372,9 +375,12 @@ fn main() { create_provider, update_provider, delete_provider, + validate_provider_config, + // Dashboard 管理命令 get_tool_instance_selection, set_tool_instance_selection, - validate_provider_config, + get_selected_provider_id, + set_selected_provider_id, ]); // 使用自定义事件循环处理 macOS Reopen 事件 diff --git a/src-tauri/src/models/dashboard.rs b/src-tauri/src/models/dashboard.rs new file mode 100644 index 0000000..5cba989 --- /dev/null +++ b/src-tauri/src/models/dashboard.rs @@ -0,0 +1,74 @@ +// Dashboard Configuration Models +// +// 仪表板状态数据模型 + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// 仪表板配置存储 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardStore { + /// 数据版本 + pub version: u32, + /// 工具实例选择记录(key: tool_id, value: instance_id) + /// 例如:{"claude-code": "claude-code-local", "codex": "codex-wsl-Ubuntu"} + pub tool_instance_selections: HashMap, + /// 最后选中的供应商 ID + pub selected_provider_id: Option, + /// 最后更新时间(Unix 时间戳) + pub updated_at: i64, +} + +impl Default for DashboardStore { + fn default() -> Self { + Self { + version: 1, + tool_instance_selections: HashMap::new(), + selected_provider_id: None, + updated_at: chrono::Utc::now().timestamp(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_dashboard_store() { + let store = DashboardStore::default(); + assert_eq!(store.version, 1); + assert!(store.tool_instance_selections.is_empty()); + assert!(store.selected_provider_id.is_none()); + assert!(store.updated_at > 0); + } + + #[test] + fn test_dashboard_store_serialization() { + let mut selections = HashMap::new(); + selections.insert("claude-code".to_string(), "claude-code-local".to_string()); + selections.insert("codex".to_string(), "codex-wsl-Ubuntu".to_string()); + + let store = DashboardStore { + version: 1, + tool_instance_selections: selections, + selected_provider_id: Some("duckcoding".to_string()), + updated_at: 1234567890, + }; + + let json = serde_json::to_string(&store).unwrap(); + let deserialized: DashboardStore = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.version, 1); + assert_eq!(deserialized.tool_instance_selections.len(), 2); + assert_eq!( + deserialized.tool_instance_selections.get("claude-code"), + Some(&"claude-code-local".to_string()) + ); + assert_eq!( + deserialized.selected_provider_id, + Some("duckcoding".to_string()) + ); + assert_eq!(deserialized.updated_at, 1234567890); + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index e05c50c..c9ea2bd 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,5 +1,6 @@ pub mod balance; pub mod config; +pub mod dashboard; pub mod provider; pub mod proxy_config; pub mod tool; @@ -7,6 +8,7 @@ pub mod update; pub use balance::*; pub use config::*; +pub use dashboard::*; pub use provider::*; // 只导出新的 proxy_config 类型,避免与 config.rs 中的旧类型冲突 pub use proxy_config::{ProxyMetadata, ProxyStore}; diff --git a/src-tauri/src/models/provider.rs b/src-tauri/src/models/provider.rs index f791794..5856b03 100644 --- a/src-tauri/src/models/provider.rs +++ b/src-tauri/src/models/provider.rs @@ -3,7 +3,6 @@ // 供应商配置数据模型 use serde::{Deserialize, Serialize}; -use std::collections::HashMap; /// 供应商配置 #[derive(Debug, Clone, Serialize, Deserialize)] @@ -28,15 +27,6 @@ pub struct Provider { pub updated_at: i64, } -/// 工具实例选择记录 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolInstanceSelection { - /// 工具ID ("claude-code" | "codex" | "gemini-cli") - pub tool_id: String, - /// 实例唯一ID(如 "claude-code-local", "codex-wsl-Ubuntu") - pub instance_id: String, -} - /// 供应商存储结构 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderStore { @@ -44,8 +34,6 @@ pub struct ProviderStore { pub version: u32, /// 供应商列表 pub providers: Vec, - /// 工具实例选择记录 - pub tool_instances: HashMap, /// 最后更新时间 pub updated_at: i64, } @@ -66,7 +54,6 @@ impl Default for ProviderStore { created_at: now, updated_at: now, }], - tool_instances: HashMap::new(), updated_at: now, } } @@ -84,7 +71,6 @@ mod tests { assert_eq!(store.providers[0].id, "duckcoding"); assert_eq!(store.providers[0].name, "DuckCoding"); assert!(store.providers[0].is_default); - assert!(store.tool_instances.is_empty()); } #[test] @@ -108,18 +94,4 @@ mod tests { assert_eq!(deserialized.name, provider.name); assert_eq!(deserialized.username, provider.username); } - - #[test] - fn test_tool_instance_selection() { - let selection = ToolInstanceSelection { - tool_id: "claude-code".to_string(), - instance_id: "claude-code-local".to_string(), - }; - - let json = serde_json::to_string(&selection).unwrap(); - let deserialized: ToolInstanceSelection = serde_json::from_str(&json).unwrap(); - - assert_eq!(deserialized.tool_id, "claude-code"); - assert_eq!(deserialized.instance_id, "claude-code-local"); - } } diff --git a/src-tauri/src/services/dashboard_manager.rs b/src-tauri/src/services/dashboard_manager.rs new file mode 100644 index 0000000..894cb81 --- /dev/null +++ b/src-tauri/src/services/dashboard_manager.rs @@ -0,0 +1,181 @@ +// Dashboard Manager Service +// +// 仪表板状态管理服务 + +use crate::data::DataManager; +use crate::models::dashboard::DashboardStore; +use crate::utils::config::config_dir; +use anyhow::Result; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +/// 仪表板状态管理器 +pub struct DashboardManager { + data_manager: Arc, + store_path: PathBuf, + cache: Arc>>, +} + +impl DashboardManager { + /// 创建新的 DashboardManager 实例 + pub fn new() -> Result { + let data_manager = Arc::new(DataManager::new()); + let store_path = config_dir() + .map_err(|e| anyhow::anyhow!("获取配置目录失败: {}", e))? + .join("dashboard.json"); + + Ok(Self { + data_manager, + store_path, + cache: Arc::new(Mutex::new(None)), + }) + } + + /// 读取存储(带缓存) + pub fn load_store(&self) -> Result { + // 检查缓存 + if let Some(cached) = self.cache.lock().unwrap().as_ref() { + return Ok(cached.clone()); + } + + // 文件不存在则返回默认值 + if !self.store_path.exists() { + tracing::warn!("dashboard.json 不存在,返回默认配置"); + let default_store = DashboardStore::default(); + // 初次创建时保存默认配置 + let _ = self.save_store(&default_store); + return Ok(default_store); + } + + // 从文件读取 + let json_value = self.data_manager.json().read(&self.store_path)?; + let store: DashboardStore = serde_json::from_value(json_value) + .map_err(|e| anyhow::anyhow!("反序列化 DashboardStore 失败: {}", e))?; + + // 更新缓存 + *self.cache.lock().unwrap() = Some(store.clone()); + + Ok(store) + } + + /// 保存存储 + fn save_store(&self, store: &DashboardStore) -> Result<()> { + let json_value = serde_json::to_value(store) + .map_err(|e| anyhow::anyhow!("序列化 DashboardStore 失败: {}", e))?; + self.data_manager + .json() + .write(&self.store_path, &json_value)?; + *self.cache.lock().unwrap() = Some(store.clone()); + Ok(()) + } + + /// 获取工具实例选择 + pub fn get_tool_instance_selection(&self, tool_id: &str) -> Result> { + Ok(self + .load_store()? + .tool_instance_selections + .get(tool_id) + .cloned()) + } + + /// 设置工具实例选择 + pub fn set_tool_instance_selection(&self, tool_id: String, instance_id: String) -> Result<()> { + let mut store = self.load_store()?; + + store.tool_instance_selections.insert(tool_id, instance_id); + store.updated_at = chrono::Utc::now().timestamp(); + + self.save_store(&store)?; + Ok(()) + } + + /// 获取最后选中的供应商 ID + pub fn get_selected_provider_id(&self) -> Result> { + Ok(self.load_store()?.selected_provider_id) + } + + /// 设置最后选中的供应商 ID + pub fn set_selected_provider_id(&self, provider_id: Option) -> Result<()> { + let mut store = self.load_store()?; + + store.selected_provider_id = provider_id; + store.updated_at = chrono::Utc::now().timestamp(); + + self.save_store(&store)?; + Ok(()) + } + + /// 清除缓存(用于测试或强制刷新) + pub fn clear_cache(&self) { + *self.cache.lock().unwrap() = None; + } +} + +impl Default for DashboardManager { + fn default() -> Self { + Self::new().expect("Failed to create DashboardManager") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dashboard_manager_creation() { + let manager = DashboardManager::new(); + assert!(manager.is_ok()); + } + + #[test] + fn test_load_default_store() { + let manager = DashboardManager::new().unwrap(); + let store = manager.load_store().unwrap(); + assert_eq!(store.version, 1); + assert!(store.tool_instance_selections.is_empty()); + assert!(store.selected_provider_id.is_none()); + } + + #[test] + fn test_tool_instance_selection() { + let manager = DashboardManager::new().unwrap(); + manager.clear_cache(); // 清除可能的旧数据 + + // 设置选择 + manager + .set_tool_instance_selection("claude-code".to_string(), "claude-code-local".to_string()) + .unwrap(); + + // 读取验证 + let selection = manager.get_tool_instance_selection("claude-code").unwrap(); + assert_eq!(selection, Some("claude-code-local".to_string())); + + // 读取不存在的工具 + let none_selection = manager.get_tool_instance_selection("unknown").unwrap(); + assert_eq!(none_selection, None); + } + + #[test] + fn test_selected_provider_id() { + let manager = DashboardManager::new().unwrap(); + manager.clear_cache(); + + // 默认为 None + let default_id = manager.get_selected_provider_id().unwrap(); + assert_eq!(default_id, None); + + // 设置供应商 ID + manager + .set_selected_provider_id(Some("duckcoding".to_string())) + .unwrap(); + + // 读取验证 + let selected_id = manager.get_selected_provider_id().unwrap(); + assert_eq!(selected_id, Some("duckcoding".to_string())); + + // 清除供应商 ID + manager.set_selected_provider_id(None).unwrap(); + let cleared_id = manager.get_selected_provider_id().unwrap(); + assert_eq!(cleared_id, None); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index d6c3d9e..467c960 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -12,6 +12,7 @@ pub mod balance; pub mod config; +pub mod dashboard_manager; // 仪表板状态管理 pub mod migration_manager; pub mod profile_manager; // Profile管理(v2.1) pub mod provider_manager; // 供应商配置管理 @@ -24,6 +25,7 @@ pub mod update; // 重新导出服务 pub use balance::*; pub use config::types::*; // 仅导出类型 +pub use dashboard_manager::DashboardManager; pub use migration_manager::{create_migration_manager, MigrationManager}; pub use profile_manager::{ ActiveStore, ClaudeProfile, CodexProfile, GeminiProfile, ProfileDescriptor, ProfileManager, diff --git a/src-tauri/src/services/provider_manager.rs b/src-tauri/src/services/provider_manager.rs index 23f98cf..fb12c0a 100644 --- a/src-tauri/src/services/provider_manager.rs +++ b/src-tauri/src/services/provider_manager.rs @@ -3,7 +3,7 @@ // 供应商配置管理服务 use crate::data::DataManager; -use crate::models::provider::{Provider, ProviderStore, ToolInstanceSelection}; +use crate::models::provider::{Provider, ProviderStore}; use crate::utils::config::config_dir; use anyhow::{anyhow, Result}; use std::path::PathBuf; @@ -133,24 +133,6 @@ impl ProviderManager { Ok(()) } - /// 获取工具实例选择 - pub fn get_tool_instance(&self, tool_id: &str) -> Result> { - Ok(self.load_store()?.tool_instances.get(tool_id).cloned()) - } - - /// 设置工具实例选择 - pub fn set_tool_instance(&self, selection: ToolInstanceSelection) -> Result<()> { - let mut store = self.load_store()?; - - store - .tool_instances - .insert(selection.tool_id.clone(), selection); - store.updated_at = chrono::Utc::now().timestamp(); - - self.save_store(&store)?; - Ok(()) - } - /// 清除缓存(用于测试或强制刷新) pub fn clear_cache(&self) { *self.cache.lock().unwrap() = None; diff --git a/src/App.tsx b/src/App.tsx index cbee7ae..031772f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -311,8 +311,9 @@ function App() { }); }, onNavigateToInstall: () => setActiveTab('install'), + onNavigateToList: () => setActiveTab('tool-management'), onNavigateToConfig: (_detail) => { - setActiveTab('tool-management'); + setActiveTab('profile-management'); // TODO: 如果需要滚动到指定工具,可以通过 _detail.toolId 实现 }, onNavigateToSettings: (detail) => { diff --git a/src/hooks/useAppEvents.ts b/src/hooks/useAppEvents.ts index 651eb7d..54f43ef 100644 --- a/src/hooks/useAppEvents.ts +++ b/src/hooks/useAppEvents.ts @@ -28,6 +28,7 @@ interface AppEventsOptions { onCloseRequest: () => void; onSingleInstance: (message: string) => void; onNavigateToInstall: () => void; + onNavigateToList: () => void; onNavigateToConfig: (detail?: { toolId?: string }) => void; onNavigateToSettings: (detail?: { tab?: string }) => void; onNavigateToTransparentProxy: (detail?: { toolId?: string }) => void; @@ -40,6 +41,7 @@ export function useAppEvents(options: AppEventsOptions) { onCloseRequest, onSingleInstance, onNavigateToInstall, + onNavigateToList, onNavigateToConfig, onNavigateToSettings, onNavigateToTransparentProxy, @@ -147,6 +149,7 @@ export function useAppEvents(options: AppEventsOptions) { window.addEventListener('navigate-to-install', onNavigateToInstall); window.addEventListener('navigate-to-config', handleNavigateToConfig); + window.addEventListener('navigate-to-list', onNavigateToList); window.addEventListener('navigate-to-settings', handleNavigateToSettings); window.addEventListener('navigate-to-transparent-proxy', handleNavigateToTransparentProxy); window.addEventListener('refresh-tools', onRefreshTools); @@ -154,6 +157,7 @@ export function useAppEvents(options: AppEventsOptions) { return () => { window.removeEventListener('navigate-to-install', onNavigateToInstall); window.removeEventListener('navigate-to-config', handleNavigateToConfig); + window.removeEventListener('navigate-to-list', onNavigateToList); window.removeEventListener('navigate-to-settings', handleNavigateToSettings); window.removeEventListener('navigate-to-transparent-proxy', handleNavigateToTransparentProxy); window.removeEventListener('refresh-tools', onRefreshTools); diff --git a/src/lib/tauri-commands/dashboard.ts b/src/lib/tauri-commands/dashboard.ts new file mode 100644 index 0000000..6d8dd21 --- /dev/null +++ b/src/lib/tauri-commands/dashboard.ts @@ -0,0 +1,38 @@ +// Dashboard 管理命令模块 +// 负责仪表板状态管理:工具实例选择、选中供应商 Tab + +import { invoke } from '@tauri-apps/api/core'; + +/** + * 获取工具实例选择 + * @param toolId 工具 ID("claude-code" | "codex" | "gemini-cli") + * @returns 实例 ID(如 "claude-code-local")或 null + */ +export async function getToolInstanceSelection(toolId: string): Promise { + return invoke('get_tool_instance_selection', { toolId }); +} + +/** + * 设置工具实例选择 + * @param toolId 工具 ID + * @param instanceId 实例 ID + */ +export async function setToolInstanceSelection(toolId: string, instanceId: string): Promise { + return invoke('set_tool_instance_selection', { toolId, instanceId }); +} + +/** + * 获取最后选中的供应商 ID + * @returns 供应商 ID 或 null + */ +export async function getSelectedProviderId(): Promise { + return invoke('get_selected_provider_id'); +} + +/** + * 设置最后选中的供应商 ID + * @param providerId 供应商 ID(传 null 表示清除) + */ +export async function setSelectedProviderId(providerId: string | null): Promise { + return invoke('set_selected_provider_id', { providerId }); +} diff --git a/src/lib/tauri-commands/index.ts b/src/lib/tauri-commands/index.ts index 6891c72..30b4566 100644 --- a/src/lib/tauri-commands/index.ts +++ b/src/lib/tauri-commands/index.ts @@ -19,6 +19,9 @@ export * from './profile'; // 供应商管理 export * from './provider'; +// Dashboard 管理 +export * from './dashboard'; + // 会话管理 export * from './session'; diff --git a/src/lib/tauri-commands/provider.ts b/src/lib/tauri-commands/provider.ts index a358400..f5f7748 100644 --- a/src/lib/tauri-commands/provider.ts +++ b/src/lib/tauri-commands/provider.ts @@ -1,13 +1,8 @@ // 供应商管理命令模块 -// 负责供应商的 CRUD、验证、工具实例选择 +// 负责供应商的 CRUD、验证 import { invoke } from '@tauri-apps/api/core'; -import type { - Provider, - ToolInstanceSelection, - _ProviderFormData, - ProviderValidationResult, -} from './types'; +import type { Provider, _ProviderFormData, ProviderValidationResult } from './types'; /** * 列出所有供应商 @@ -37,22 +32,6 @@ export async function deleteProvider(id: string): Promise { return invoke('delete_provider', { id }); } -/** - * 获取工具实例选择 - */ -export async function getToolInstanceSelection( - toolId: string, -): Promise { - return invoke('get_tool_instance_selection', { toolId }); -} - -/** - * 设置工具实例选择 - */ -export async function setToolInstanceSelection(selection: ToolInstanceSelection): Promise { - return invoke('set_tool_instance_selection', { selection }); -} - /** * 验证供应商配置(检查 API 连通性,获取用户名) */ diff --git a/src/lib/tauri-commands/types.ts b/src/lib/tauri-commands/types.ts index ca70bf5..54bf9c4 100644 --- a/src/lib/tauri-commands/types.ts +++ b/src/lib/tauri-commands/types.ts @@ -6,7 +6,6 @@ import type { ProfileData, ProfileDescriptor, ProfilePayload, ToolId } from '@/t import type { Provider, ProviderStore, - ToolInstanceSelection, _ProviderFormData, ProviderValidationResult, } from '@/types/provider'; @@ -18,13 +17,7 @@ export type { ProfileData, ProfileDescriptor, ProfilePayload, ToolId }; export type { SSHConfig }; // 重新导出供应商管理类型 -export type { - Provider, - ProviderStore, - ToolInstanceSelection, - _ProviderFormData, - ProviderValidationResult, -}; +export type { Provider, ProviderStore, _ProviderFormData, ProviderValidationResult }; export interface ToolStatus { mirrorIsStale: boolean; diff --git a/src/pages/DashboardPage/components/DashboardToolCard.tsx b/src/pages/DashboardPage/components/DashboardToolCard.tsx index 2be7367..6d6daed 100644 --- a/src/pages/DashboardPage/components/DashboardToolCard.tsx +++ b/src/pages/DashboardPage/components/DashboardToolCard.tsx @@ -12,7 +12,6 @@ import { RefreshCw, Loader2, Key, Monitor, CheckCircle2, Package } from 'lucide- import { logoMap } from '@/utils/constants'; import { formatVersionLabel } from '@/utils/formatting'; import type { ToolStatus } from '@/lib/tauri-commands'; -import type { ToolInstanceSelection } from '@/types/provider'; import type { ToolInstance } from '@/types/tool-management'; interface DashboardToolCardProps { @@ -20,14 +19,15 @@ interface DashboardToolCardProps { updating: boolean; checking: boolean; // 当前工具是否正在检测更新 checkingAll: boolean; // 全局检测更新状态 - instanceSelection?: ToolInstanceSelection; + instanceSelection?: string; // 实例ID字符串 instanceOptions: Array<{ value: string; label: string }>; // 实例选项列表 toolInstances: ToolInstance[]; // 工具实例数据 onUpdate: () => void; onCheckUpdates: () => void; onConfigure: () => void; onInstanceChange: (instanceType: string) => void; - onInstall: () => void; // 新增:前往安装页面 + onInstall: () => void; // 新增:前往安装页面 + onAdd: () => void; // 新增:前往添加页面 } export function DashboardToolCard({ @@ -43,14 +43,15 @@ export function DashboardToolCard({ onConfigure, onInstanceChange, onInstall, + onAdd, }: DashboardToolCardProps) { // 是否正在检测更新(全局或单工具) const isChecking = checking || checkingAll; // 已检测完成且是最新版(确保只在检测更新后才显示) const isLatest = tool.hasUpdate === false && Boolean(tool.latestVersion); - // 直接使用保存的 instance_id,如果不存在则使用第一个选项 - const currentInstanceId = instanceSelection?.instance_id || instanceOptions[0]?.value || ''; + // 直接使用保存的实例ID,如果不存在则使用第一个选项 + const currentInstanceId = instanceSelection || instanceOptions[0]?.value || ''; // 从 toolInstances 中找到当前选中的实例 const currentInstance = toolInstances.find((inst) => inst.instance_id === currentInstanceId); @@ -156,29 +157,35 @@ export function DashboardToolCard({ 状态: - 未安装 + 未安装或者没有被软件识别
)}
- - {!tool.installed ? ( + ) : ( + + )} + {!tool.installed ? ( + ) : tool.hasUpdate ? (
diff --git a/src/types/provider.ts b/src/types/provider.ts index 72fec0a..60c0199 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -26,16 +26,6 @@ export interface Provider { updated_at: number; } -/** - * 工具实例选择 - */ -export interface ToolInstanceSelection { - /** 工具ID("claude-code" | "codex" | "gemini-cli") */ - tool_id: string; - /** 实例唯一ID(如 "claude-code-local", "codex-wsl-Ubuntu") */ - instance_id: string; -} - /** * 供应商存储结构 */ @@ -46,8 +36,6 @@ export interface ProviderStore { providers: Provider[]; /** 当前激活的供应商ID */ active_provider_id?: string; - /** 工具实例选择映射(key: tool_id, value: selection) */ - tool_instances: Record; /** 最后更新时间(Unix timestamp) */ updated_at: number; } From 16ad1fc765789498e75220b7acdfca7bbcaa0e8b Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Sat, 3 Jan 2026 11:01:29 +0800 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E9=9D=A2=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=86=97?= =?UTF-8?q?=E4=BD=99=E8=B4=A6=E6=88=B7=E9=85=8D=E7=BD=AE=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=B1=82=E7=BA=A7=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更内容 ### 1. 移除冗余的 DuckCoding 账户配置 - 删除旧的 BasicSettingsTab(包含 user_id/system_token 字段) - 删除 ApplicationSettingsTab.tsx - 从 useSettingsForm hook 中移除所有账户相关状态和逻辑 - 从 SettingsPage 移除账户字段的解构和传递 **原因**: 引入供应商系统后,每个 Provider 已独立管理凭证,全局账户配置不再需要 ### 2. 优化设置页面层级结构 - 将 ApplicationSettingsTab 内容重构为新的 BasicSettingsTab - 修改 Tab 标签:「基本设置」→「系统设置」 - 移除「应用设置」Tab,整合到「系统设置」 - 内部分两个 section: - 「基本设置」section:开机自启动 - 「开发者设置」section:单实例模式 ### 3. 优化组件设计 - BasicSettingsTab 改为自包含组件(无 props,内部管理状态) - 保存按钮仅在「代理设置」Tab 显示(系统设置内部自管理) - useSettingsForm hook 职责缩减为仅管理代理设置 ## 架构改进 - **SOLID - SRP**: BasicSettingsTab 单一职责,useSettingsForm 职责更清晰 - **DRY**: 消除双数据流(全局配置 vs 供应商配置) - **YAGNI**: 移除不再使用的账户配置代码 ## 测试 ✅ npm run check 全部通过 ✅ ESLint/Clippy/Prettier/fmt 零警告 --- .../components/ApplicationSettingsTab.tsx | 199 --------------- .../components/BasicSettingsTab.tsx | 241 ++++++++++++++---- .../SettingsPage/hooks/useSettingsForm.ts | 23 -- src/pages/SettingsPage/index.tsx | 31 +-- 4 files changed, 192 insertions(+), 302 deletions(-) delete mode 100644 src/pages/SettingsPage/components/ApplicationSettingsTab.tsx diff --git a/src/pages/SettingsPage/components/ApplicationSettingsTab.tsx b/src/pages/SettingsPage/components/ApplicationSettingsTab.tsx deleted file mode 100644 index 3bbe945..0000000 --- a/src/pages/SettingsPage/components/ApplicationSettingsTab.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Switch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; -import { Button } from '@/components/ui/button'; -import { Settings as SettingsIcon, Info, RefreshCw, Loader2 } from 'lucide-react'; -import { useToast } from '@/hooks/use-toast'; -import { - getSingleInstanceConfig, - updateSingleInstanceConfig, - getStartupConfig, - updateStartupConfig, -} from '@/lib/tauri-commands'; - -export function ApplicationSettingsTab() { - const [singleInstanceEnabled, setSingleInstanceEnabled] = useState(true); - const [startupEnabled, setStartupEnabled] = useState(false); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const { toast } = useToast(); - - // 加载配置 - useEffect(() => { - const loadConfig = async () => { - setLoading(true); - try { - const [singleInstance, startup] = await Promise.all([ - getSingleInstanceConfig(), - getStartupConfig(), - ]); - setSingleInstanceEnabled(singleInstance); - setStartupEnabled(startup); - } catch (error) { - console.error('加载配置失败:', error); - toast({ - title: '加载失败', - description: String(error), - variant: 'destructive', - }); - } finally { - setLoading(false); - } - }; - - loadConfig(); - }, [toast]); - - // 保存单实例配置 - const handleSingleInstanceToggle = async (checked: boolean) => { - setSaving(true); - try { - await updateSingleInstanceConfig(checked); - setSingleInstanceEnabled(checked); - toast({ - title: '设置已保存', - description: ( -
-

请重启应用以使更改生效

- -
- ), - }); - } catch (error) { - console.error('保存单实例配置失败:', error); - toast({ - title: '保存失败', - description: String(error), - variant: 'destructive', - }); - } finally { - setSaving(false); - } - }; - - // 保存开机自启动配置 - const handleStartupToggle = async (checked: boolean) => { - setSaving(true); - try { - await updateStartupConfig(checked); - setStartupEnabled(checked); - toast({ - title: '设置已保存', - description: checked ? '已启用开机自启动' : '已禁用开机自启动', - }); - } catch (error) { - console.error('保存开机自启动配置失败:', error); - toast({ - title: '保存失败', - description: String(error), - variant: 'destructive', - }); - } finally { - setSaving(false); - } - }; - - return ( -
-
- -

应用行为

-
- - -
-
-
- -

- 启用后,同时只能运行一个应用实例(生产环境) -

-
- -
- -
-
- -
-

关于单实例模式

-
    -
  • - 启用(推荐):打开第二个实例时会聚焦到第一个窗口,节省系统资源 -
  • -
  • - 禁用:允许同时运行多个实例,适用于多账户测试或特殊需求 -
  • -
  • - 开发环境:始终允许多实例(与正式版隔离) -
  • -
  • - 生效方式:更改后需要重启应用才能生效 -
  • -
-
-
-
- - - -
-
- -

开机时自动启动 DuckCoding 应用

-
- -
- -
-
- -
-

关于开机自启动

-
    -
  • - 启用:系统启动时自动运行应用,方便快速访问 -
  • -
  • - 禁用:需要手动启动应用 -
  • -
  • - 跨平台支持:Windows、macOS、Linux 均支持此功能 -
  • -
  • - 生效方式:更改后立即生效,无需重启应用 -
  • -
-
-
-
- - {(loading || saving) && ( -
- - {loading ? '加载中...' : '保存中...'} -
- )} -
-
- ); -} diff --git a/src/pages/SettingsPage/components/BasicSettingsTab.tsx b/src/pages/SettingsPage/components/BasicSettingsTab.tsx index 3f2ebf2..bed8530 100644 --- a/src/pages/SettingsPage/components/BasicSettingsTab.tsx +++ b/src/pages/SettingsPage/components/BasicSettingsTab.tsx @@ -1,75 +1,208 @@ -import { Input } from '@/components/ui/input'; +import { useEffect, useState } from 'react'; +import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; -import { Settings as SettingsIcon, Info } from 'lucide-react'; -import { openExternalLink } from '@/utils/formatting'; +import { Button } from '@/components/ui/button'; +import { Settings as SettingsIcon, Info, RefreshCw, Loader2 } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { + getSingleInstanceConfig, + updateSingleInstanceConfig, + getStartupConfig, + updateStartupConfig, +} from '@/lib/tauri-commands'; -interface BasicSettingsTabProps { - userId: string; - setUserId: (value: string) => void; - systemToken: string; - setSystemToken: (value: string) => void; -} +export function BasicSettingsTab() { + const [singleInstanceEnabled, setSingleInstanceEnabled] = useState(true); + const [startupEnabled, setStartupEnabled] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const { toast } = useToast(); + + // 加载配置 + useEffect(() => { + const loadConfig = async () => { + setLoading(true); + try { + const [singleInstance, startup] = await Promise.all([ + getSingleInstanceConfig(), + getStartupConfig(), + ]); + setSingleInstanceEnabled(singleInstance); + setStartupEnabled(startup); + } catch (error) { + console.error('加载配置失败:', error); + toast({ + title: '加载失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + loadConfig(); + }, [toast]); + + // 保存单实例配置 + const handleSingleInstanceToggle = async (checked: boolean) => { + setSaving(true); + try { + await updateSingleInstanceConfig(checked); + setSingleInstanceEnabled(checked); + toast({ + title: '设置已保存', + description: ( +
+

请重启应用以使更改生效

+ +
+ ), + }); + } catch (error) { + console.error('保存单实例配置失败:', error); + toast({ + title: '保存失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setSaving(false); + } + }; + + // 保存开机自启动配置 + const handleStartupToggle = async (checked: boolean) => { + setSaving(true); + try { + await updateStartupConfig(checked); + setStartupEnabled(checked); + toast({ + title: '设置已保存', + description: checked ? '已启用开机自启动' : '已禁用开机自启动', + }); + } catch (error) { + console.error('保存开机自启动配置失败:', error); + toast({ + title: '保存失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setSaving(false); + } + }; -export function BasicSettingsTab({ - userId, - setUserId, - systemToken, - setSystemToken, -}: BasicSettingsTabProps) { return (
-

DuckCoding 账户

+

系统设置

-
-
- - setUserId(e.target.value)} - className="shadow-sm" - /> -

用于识别您的账户和一键生成 API Key

-
+
+ {/* 基本设置分类 */} +
+

基本设置

+ +
+
+ +

开机时自动启动 DuckCoding 应用

+
+ +
-
- - setSystemToken(e.target.value)} - className="shadow-sm" - /> -

用于验证您的身份和调用系统 API

+
+
+ +
+

关于开机自启动

+
    +
  • + 启用:系统启动时自动运行应用,方便快速访问 +
  • +
  • + 禁用:需要手动启动应用 +
  • +
  • + 跨平台支持:Windows、macOS、Linux 均支持此功能 +
  • +
  • + 生效方式:更改后立即生效,无需重启应用 +
  • +
+
+
+
-
-
- -
-

- 如何获取用户 ID 和系统访问令牌? -

-

- 请访问 DuckCoding 控制台获取您的凭证信息 + + + {/* 开发者设置分类 */} +

+

开发者设置

+ +
+
+ +

+ 启用后,同时只能运行一个应用实例(生产环境)

- +
+ +
+ +
+
+ +
+

关于单实例模式

+
    +
  • + 启用(推荐):打开第二个实例时会聚焦到第一个窗口,节省系统资源 +
  • +
  • + 禁用:允许同时运行多个实例,适用于多账户测试或特殊需求 +
  • +
  • + 开发环境:始终允许多实例(与正式版隔离) +
  • +
  • + 生效方式:更改后需要重启应用才能生效 +
  • +
+
+ + {(loading || saving) && ( +
+ + {loading ? '加载中...' : '保存中...'} +
+ )}
); diff --git a/src/pages/SettingsPage/hooks/useSettingsForm.ts b/src/pages/SettingsPage/hooks/useSettingsForm.ts index 4ed7468..e298a35 100644 --- a/src/pages/SettingsPage/hooks/useSettingsForm.ts +++ b/src/pages/SettingsPage/hooks/useSettingsForm.ts @@ -13,10 +13,6 @@ interface UseSettingsFormProps { } export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFormProps) { - // 基本设置状态 - const [userId, setUserId] = useState(''); - const [systemToken, setSystemToken] = useState(''); - // 代理设置状态 const [proxyEnabled, setProxyEnabled] = useState(false); const [proxyType, setProxyType] = useState<'http' | 'https' | 'socks5'>('http'); @@ -47,8 +43,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo setGlobalConfig(initialConfig); // 填充表单 - setUserId(initialConfig.user_id || ''); - setSystemToken(initialConfig.system_token || ''); setProxyEnabled(initialConfig.proxy_enabled || false); setProxyType(initialConfig.proxy_type || 'http'); setProxyHost(initialConfig.proxy_host || ''); @@ -70,13 +64,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo // 保存配置 const saveSettings = useCallback(async (): Promise => { - const trimmedUserId = userId.trim(); - const trimmedToken = systemToken.trim(); - - if (!trimmedUserId || !trimmedToken) { - throw new Error('用户ID和系统访问令牌不能为空'); - } - const proxyPortNumber = proxyPort ? parseInt(proxyPort) : 0; if (proxyEnabled && (!proxyHost.trim() || proxyPortNumber <= 0)) { throw new Error('代理地址和端口不能为空'); @@ -85,8 +72,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo setSavingSettings(true); try { const configToSave: GlobalConfig = { - user_id: trimmedUserId, - system_token: trimmedToken, proxy_enabled: proxyEnabled, proxy_type: proxyType, proxy_host: proxyHost.trim(), @@ -104,8 +89,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo setSavingSettings(false); } }, [ - userId, - systemToken, proxyEnabled, proxyType, proxyHost, @@ -203,12 +186,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo }, [proxyEnabled, proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword, proxyTestUrl]); return { - // Basic settings - userId, - setUserId, - systemToken, - setSystemToken, - // Proxy settings proxyEnabled, setProxyEnabled, diff --git a/src/pages/SettingsPage/index.tsx b/src/pages/SettingsPage/index.tsx index a66fcec..5f003bf 100644 --- a/src/pages/SettingsPage/index.tsx +++ b/src/pages/SettingsPage/index.tsx @@ -6,7 +6,6 @@ import { PageContainer } from '@/components/layout/PageContainer'; import { useToast } from '@/hooks/use-toast'; import { useSettingsForm } from './hooks/useSettingsForm'; import { BasicSettingsTab } from './components/BasicSettingsTab'; -import { ApplicationSettingsTab } from './components/ApplicationSettingsTab'; import { ProxySettingsTab } from './components/ProxySettingsTab'; import { LogSettingsTab } from './components/LogSettingsTab'; import { AboutTab } from './components/AboutTab'; @@ -49,10 +48,6 @@ export function SettingsPage({ // 使用自定义 Hooks const { - userId, - setUserId, - systemToken, - setSystemToken, proxyEnabled, setProxyEnabled, proxyType, @@ -165,13 +160,7 @@ export function SettingsPage({ - 基本设置 - - - 应用设置 + 系统设置 - {/* 基本设置 */} + {/* 系统设置 */} - - - - {/* 应用设置 */} - - + {/* 代理设置 */} @@ -245,8 +224,8 @@ export function SettingsPage({ - {/* 保存按钮 - 仅在基本设置和代理设置时显示 */} - {(activeTab === 'basic' || activeTab === 'proxy') && ( + {/* 保存按钮 - 仅在代理设置时显示 */} + {activeTab === 'proxy' && (