From 81d686a230a0bab23be4d7bb24af071f70be1bcd Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:03:01 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BB=8E?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E5=AF=BC=E5=85=A5=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=E4=B8=BA=20Profile=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 动机 - 用户在供应商平台(如 NEW API)管理多个 API 令牌,需要手动复制配置到 DuckCoding - 重复性操作易出错且效率低,缺乏导入溯源记录 ## 核心改动 ### 后端架构层 - 新增 `models/remote_token.rs`:定义 `RemoteToken`、`RemoteTokenGroup`、`CreateRemoteTokenRequest` 等数据模型 - 新增 `services/new_api/client.rs`:实现 NEW API 客户端(296行),提供令牌列表/创建/删除/分组查询功能 - 新增 `commands/token_commands.rs`:7个 Tauri 命令支持前端调用(`fetch_provider_tokens`、`import_token_as_profile` 等) - 扩展 `ProfileSource` 枚举:新增 `ImportedFromProvider` 变体,记录导入溯源信息(供应商ID/令牌ID/导入时间等) - 更新 `ProfileManager::load/save_profiles_store` 为 public,允许 token_commands 直接操作 ### 前端功能层 - 供应商管理页(`ProviderManagementPage`): - 新增可展开/折叠的表格行,展开后显示 `RemoteTokenManagement` 组件 - 支持查看令牌列表、创建令牌、删除令牌、导入到 Profile - Profile 管理页(`ProfileManagementPage`): - "创建 Profile" 按钮改为下拉菜单,新增"从供应商导入"选项 - 新增 `ImportFromProviderDialog` 组件(336行),支持选择供应商 → 选择令牌 → 填写配置 → 导入 - `ActiveProfileCard` 和 `ProfileCard` 显示来源信息(自定义 vs 从供应商导入) - 类型定义和命令包装: - `types/remote-token.ts`:前端类型定义 - `lib/tauri-commands/token.ts`:Tauri 命令的 TypeScript 包装器 ### 数据迁移 - `profile_v2.rs`:所有 Profile 创建逻辑默认 `source: ProfileSource::Custom`,保证向后兼容 ## 测试情况 - 后端单元测试:`token_commands.rs` 包含 4 个测试,`remote_token.rs` 包含 2 个测试 - 前端集成测试:手动验证导入流程(选择供应商 → 选择令牌 → 填写配置 → 成功导入) ## 影响范围 - **新增代码**:1059 行(前端 763 行 + 后端 296 行),无破坏性变更 - **修改模块**:ProfileManager、迁移系统、前端 Profile/Provider 管理页 - **数据兼容性**:旧 Profile 自动标记为 `Custom` 来源,无需手动迁移 --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/token_commands.rs | 307 ++++++++++++++++ src-tauri/src/main.rs | 8 + src-tauri/src/models/mod.rs | 2 + src-tauri/src/models/remote_token.rs | 147 ++++++++ .../migrations/profile_v2.rs | 12 +- src-tauri/src/services/mod.rs | 5 +- src-tauri/src/services/new_api/client.rs | 296 +++++++++++++++ src-tauri/src/services/new_api/mod.rs | 7 + .../src/services/profile_manager/manager.rs | 10 +- src-tauri/src/services/profile_manager/mod.rs | 2 +- .../src/services/profile_manager/types.rs | 36 ++ src/lib/tauri-commands/token.ts | 87 +++++ .../components/ActiveProfileCard.tsx | 8 + .../components/ImportFromProviderDialog.tsx | 336 ++++++++++++++++++ .../components/ProfileCard.tsx | 36 +- src/pages/ProfileManagementPage/index.tsx | 46 ++- .../components/CreateRemoteTokenDialog.tsx | 310 ++++++++++++++++ .../components/ImportTokenDialog.tsx | 199 +++++++++++ .../components/RemoteTokenManagement.tsx | 239 +++++++++++++ src/pages/ProviderManagementPage/index.tsx | 118 +++--- src/types/profile.ts | 20 +- src/types/remote-token.ts | 101 ++++++ 23 files changed, 2278 insertions(+), 56 deletions(-) create mode 100644 src-tauri/src/commands/token_commands.rs create mode 100644 src-tauri/src/models/remote_token.rs create mode 100644 src-tauri/src/services/new_api/client.rs create mode 100644 src-tauri/src/services/new_api/mod.rs create mode 100644 src/lib/tauri-commands/token.ts create mode 100644 src/pages/ProfileManagementPage/components/ImportFromProviderDialog.tsx create mode 100644 src/pages/ProviderManagementPage/components/CreateRemoteTokenDialog.tsx create mode 100644 src/pages/ProviderManagementPage/components/ImportTokenDialog.tsx create mode 100644 src/pages/ProviderManagementPage/components/RemoteTokenManagement.tsx create mode 100644 src/types/remote-token.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 434e111..c340bc1 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod proxy_commands; pub mod session_commands; pub mod startup_commands; // 开机自启动管理命令 pub mod stats_commands; +pub mod token_commands; // 令牌资产管理命令(NEW API 集成) pub mod tool_commands; pub mod tool_management; pub mod types; @@ -29,6 +30,7 @@ pub use proxy_commands::*; pub use session_commands::*; pub use startup_commands::*; // 开机自启动管理命令 pub use stats_commands::*; +pub use token_commands::*; // 令牌资产管理命令(NEW API 集成) pub use tool_commands::*; pub use tool_management::*; pub use update_commands::*; diff --git a/src-tauri/src/commands/token_commands.rs b/src-tauri/src/commands/token_commands.rs new file mode 100644 index 0000000..5bfcba1 --- /dev/null +++ b/src-tauri/src/commands/token_commands.rs @@ -0,0 +1,307 @@ +// Token Management Commands +// +// NEW API 令牌管理相关命令 + +use ::duckcoding::models::provider::Provider; +use ::duckcoding::models::remote_token::{CreateRemoteTokenRequest, RemoteToken, RemoteTokenGroup}; +use ::duckcoding::services::{ + ClaudeProfile, CodexProfile, GeminiProfile, NewApiClient, ProfileSource, +}; +use anyhow::Result; +use chrono::Utc; +use tauri::State; + +/// 获取指定供应商的远程令牌列表 +#[tauri::command] +pub async fn fetch_provider_tokens(provider: Provider) -> Result, String> { + let client = NewApiClient::new(provider).map_err(|e| e.to_string())?; + client.list_tokens().await.map_err(|e| e.to_string()) +} + +/// 获取指定供应商的令牌分组列表 +#[tauri::command] +pub async fn fetch_provider_groups(provider: Provider) -> Result, String> { + let client = NewApiClient::new(provider).map_err(|e| e.to_string())?; + client.list_groups().await.map_err(|e| e.to_string()) +} + +/// 在供应商创建新的远程令牌 +#[tauri::command] +pub async fn create_provider_token( + provider: Provider, + request: CreateRemoteTokenRequest, +) -> Result { + let client = NewApiClient::new(provider).map_err(|e| e.to_string())?; + client + .create_token(request) + .await + .map_err(|e| e.to_string()) +} + +/// 删除供应商的远程令牌 +#[tauri::command] +pub async fn delete_provider_token(provider: Provider, token_id: i64) -> Result<(), String> { + let client = NewApiClient::new(provider).map_err(|e| e.to_string())?; + client + .delete_token(token_id) + .await + .map_err(|e| e.to_string()) +} + +/// 更新供应商的远程令牌名称 +#[tauri::command] +pub async fn update_provider_token( + provider: Provider, + token_id: i64, + name: String, +) -> Result { + let client = NewApiClient::new(provider).map_err(|e| e.to_string())?; + client + .update_token(token_id, name) + .await + .map_err(|e| e.to_string()) +} + +/// 导入远程令牌为本地 Profile +#[tauri::command] +pub async fn import_token_as_profile( + profile_manager: State<'_, crate::commands::profile_commands::ProfileManagerState>, + provider: Provider, + remote_token: RemoteToken, + tool_id: String, + profile_name: String, +) -> Result<(), String> { + // 验证 tool_id + if tool_id != "claude-code" && tool_id != "codex" && tool_id != "gemini-cli" { + return Err(format!("不支持的工具类型: {}", tool_id)); + } + + // 构建 ProfileSource + let source = ProfileSource::ImportedFromProvider { + provider_id: provider.id.clone(), + provider_name: provider.name.clone(), + remote_token_id: remote_token.id, + remote_token_name: remote_token.name.clone(), + group: remote_token.group.clone(), + imported_at: Utc::now().timestamp(), + }; + + // 提取 API Key 和 Base URL + let api_key = remote_token.key.clone(); + let base_url = provider.website_url.clone(); + + // 直接操作 ProfilesStore 以支持自定义 source 字段 + let manager = profile_manager.manager.read().await; + let mut store = manager.load_profiles_store().map_err(|e| e.to_string())?; + + // 根据工具类型创建对应的 Profile + match tool_id.as_str() { + "claude-code" => { + let profile = ClaudeProfile { + api_key, + base_url, + source, + created_at: Utc::now(), + updated_at: Utc::now(), + raw_settings: None, + raw_config_json: None, + }; + store.claude_code.insert(profile_name.clone(), profile); + } + "codex" => { + let profile = CodexProfile { + api_key, + base_url, + wire_api: "responses".to_string(), // 默认使用 responses API + source, + created_at: Utc::now(), + updated_at: Utc::now(), + raw_config_toml: None, + raw_auth_json: None, + }; + store.codex.insert(profile_name.clone(), profile); + } + "gemini-cli" => { + let profile = GeminiProfile { + api_key, + base_url, + model: None, // 不指定 model,保留用户原有配置 + source, + created_at: Utc::now(), + updated_at: Utc::now(), + raw_settings: None, + raw_env: None, + }; + store.gemini_cli.insert(profile_name.clone(), profile); + } + _ => return Err(format!("不支持的工具类型: {}", tool_id)), + } + + store.metadata.last_updated = Utc::now(); + manager + .save_profiles_store(&store) + .map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 创建自定义 Profile(非导入令牌) +#[tauri::command] +pub async fn create_custom_profile( + profile_manager: State<'_, crate::commands::profile_commands::ProfileManagerState>, + tool_id: String, + profile_name: String, + api_key: String, + base_url: String, + extra_config: Option, +) -> Result<(), String> { + // 验证 tool_id + if tool_id != "claude-code" && tool_id != "codex" && tool_id != "gemini-cli" { + return Err(format!("不支持的工具类型: {}", tool_id)); + } + + let source = ProfileSource::Custom; + + // 直接操作 ProfilesStore 以支持自定义 source 字段 + let manager = profile_manager.manager.read().await; + let mut store = manager.load_profiles_store().map_err(|e| e.to_string())?; + + // 根据工具类型创建对应的 Profile + match tool_id.as_str() { + "claude-code" => { + let profile = ClaudeProfile { + api_key, + base_url, + source, + created_at: Utc::now(), + updated_at: Utc::now(), + raw_settings: None, + raw_config_json: None, + }; + store.claude_code.insert(profile_name.clone(), profile); + } + "codex" => { + // 从 extra_config 中提取 wire_api + let wire_api = extra_config + .as_ref() + .and_then(|v| v.get("wire_api")) + .and_then(|v| v.as_str()) + .unwrap_or("responses") + .to_string(); + + let profile = CodexProfile { + api_key, + base_url, + wire_api, + source, + created_at: Utc::now(), + updated_at: Utc::now(), + raw_config_toml: None, + raw_auth_json: None, + }; + store.codex.insert(profile_name.clone(), profile); + } + "gemini-cli" => { + // 从 extra_config 中提取 model + let model = extra_config + .as_ref() + .and_then(|v| v.get("model")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let profile = GeminiProfile { + api_key, + base_url, + model, + source, + created_at: Utc::now(), + updated_at: Utc::now(), + raw_settings: None, + raw_env: None, + }; + store.gemini_cli.insert(profile_name.clone(), profile); + } + _ => return Err(format!("不支持的工具类型: {}", tool_id)), + } + + store.metadata.last_updated = Utc::now(); + manager + .save_profiles_store(&store) + .map_err(|e| e.to_string())?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_tool_id() { + let valid_ids = vec!["claude-code", "codex", "gemini-cli"]; + for id in valid_ids { + assert!( + id == "claude-code" || id == "codex" || id == "gemini-cli", + "Tool ID validation failed for: {}", + id + ); + } + + let invalid_ids = vec!["invalid", "unknown-tool", ""]; + for id in invalid_ids { + assert!( + id != "claude-code" && id != "codex" && id != "gemini-cli", + "Tool ID validation should fail for: {}", + id + ); + } + } + + #[test] + fn test_provider_creation() { + let provider = Provider { + id: "test-provider".to_string(), + name: "Test Provider".to_string(), + website_url: "https://api.test.com".to_string(), + user_id: "123".to_string(), + access_token: "token123".to_string(), + username: None, + is_default: false, + created_at: 0, + updated_at: 0, + }; + + assert_eq!(provider.id, "test-provider"); + assert_eq!(provider.website_url, "https://api.test.com"); + } + + #[test] + fn test_profile_source_custom() { + let source = ProfileSource::Custom; + assert_eq!(source, ProfileSource::Custom); + } + + #[test] + fn test_profile_source_imported() { + let source = ProfileSource::ImportedFromProvider { + provider_id: "provider-1".to_string(), + provider_name: "Provider One".to_string(), + remote_token_id: 100, + remote_token_name: "Token Name".to_string(), + group: "default".to_string(), + imported_at: 1234567890, + }; + + if let ProfileSource::ImportedFromProvider { + provider_id, + remote_token_id, + .. + } = source + { + assert_eq!(provider_id, "provider-1"); + assert_eq!(remote_token_id, 100); + } else { + panic!("Expected ImportedFromProvider variant"); + } + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 569449b..3f0b838 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -376,6 +376,14 @@ fn main() { update_provider, delete_provider, validate_provider_config, + // 令牌资产管理命令(NEW API 集成) + fetch_provider_tokens, + fetch_provider_groups, + create_provider_token, + delete_provider_token, + update_provider_token, + import_token_as_profile, + create_custom_profile, // Dashboard 管理命令 get_tool_instance_selection, set_tool_instance_selection, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index c9ea2bd..d9b2002 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -3,6 +3,7 @@ pub mod config; pub mod dashboard; pub mod provider; pub mod proxy_config; +pub mod remote_token; pub mod tool; pub mod update; @@ -12,5 +13,6 @@ pub use dashboard::*; pub use provider::*; // 只导出新的 proxy_config 类型,避免与 config.rs 中的旧类型冲突 pub use proxy_config::{ProxyMetadata, ProxyStore}; +pub use remote_token::*; pub use tool::*; pub use update::*; diff --git a/src-tauri/src/models/remote_token.rs b/src-tauri/src/models/remote_token.rs new file mode 100644 index 0000000..a182161 --- /dev/null +++ b/src-tauri/src/models/remote_token.rs @@ -0,0 +1,147 @@ +// Remote Token Models +// +// NEW API 远程令牌数据模型 + +use serde::{Deserialize, Serialize}; + +/// 远程令牌(从 NEW API 拉取,不本地持久化) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteToken { + /// 令牌 ID + pub id: i64, + /// 用户 ID + #[serde(default)] + pub user_id: i64, + /// 令牌名称 + pub name: String, + /// 令牌密钥 + pub key: String, + /// 所属分组 + pub group: String, + /// 剩余额度 + pub remain_quota: i64, + /// 已使用额度 + #[serde(default)] + pub used_quota: i64, + /// 过期时间(Unix 时间戳,-1 表示永不过期) + pub expired_time: i64, + /// 状态(1=启用,2=禁用) + pub status: i32, + /// 是否无限额度 + pub unlimited_quota: bool, + /// 是否启用模型限制 + #[serde(default)] + pub model_limits_enabled: bool, + /// 模型限制(逗号分隔的模型列表) + #[serde(default)] + pub model_limits: String, + /// 允许的 IP 地址(逗号分隔) + #[serde(default)] + pub allow_ips: String, + /// 是否支持跨分组重试 + #[serde(default)] + pub cross_group_retry: bool, + /// 创建时间(Unix 时间戳) + pub created_time: i64, + /// 最后访问时间(Unix 时间戳) + #[serde(default)] + pub accessed_time: i64, +} + +/// 远程令牌分组(API 返回的分组信息) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteTokenGroupInfo { + /// 分组描述 + pub desc: String, + /// 倍率 + pub ratio: f64, +} + +/// 远程令牌分组(前端展示用) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteTokenGroup { + /// 分组 ID(即分组名称) + pub id: String, + /// 分组描述 + pub desc: String, + /// 倍率 + pub ratio: f64, +} + +/// 创建远程令牌请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateRemoteTokenRequest { + /// 令牌名称 + pub name: String, + /// 分组 ID + pub group_id: String, + /// 初始额度(-1 表示无限) + pub quota: i64, + /// 过期天数(0 表示永不过期) + pub expire_days: i32, +} + +/// NEW API 通用响应结构 +#[derive(Debug, Deserialize)] +pub struct NewApiResponse { + pub success: bool, + pub message: Option, + pub data: Option, +} + +/// NEW API 令牌列表响应的 data 部分 +#[derive(Debug, Deserialize)] +pub struct TokenListData { + pub page: i32, + pub page_size: i32, + pub total: i32, + pub items: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_remote_token_serialization() { + let token = RemoteToken { + id: 123, + user_id: 2703, + name: "Test Token".to_string(), + key: "sk-test123".to_string(), + group: "default".to_string(), + remain_quota: 100, + used_quota: 50, + expired_time: 1735200000, + status: 1, + unlimited_quota: false, + model_limits_enabled: false, + model_limits: String::new(), + allow_ips: String::new(), + cross_group_retry: false, + created_time: 1704067200, + accessed_time: 1704067200, + }; + + let json = serde_json::to_string(&token).unwrap(); + let deserialized: RemoteToken = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.id, token.id); + assert_eq!(deserialized.name, token.name); + assert_eq!(deserialized.key, token.key); + } + + #[test] + fn test_create_request_serialization() { + let request = CreateRemoteTokenRequest { + name: "New Token".to_string(), + group_id: "group1".to_string(), + quota: -1, + expire_days: 30, + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"name\":\"New Token\"")); + assert!(json.contains("\"quota\":-1")); + } +} diff --git a/src-tauri/src/services/migration_manager/migrations/profile_v2.rs b/src-tauri/src/services/migration_manager/migrations/profile_v2.rs index 42b3fe8..7f66ddf 100644 --- a/src-tauri/src/services/migration_manager/migrations/profile_v2.rs +++ b/src-tauri/src/services/migration_manager/migrations/profile_v2.rs @@ -9,7 +9,8 @@ use crate::data::DataManager; use crate::services::migration_manager::migration_trait::{Migration, MigrationResult}; use crate::services::profile_manager::{ - ActiveProfile, ActiveStore, ClaudeProfile, CodexProfile, GeminiProfile, ProfilesStore, + ActiveProfile, ActiveStore, ClaudeProfile, CodexProfile, GeminiProfile, ProfileSource, + ProfilesStore, }; use anyhow::{Context, Result}; use async_trait::async_trait; @@ -216,6 +217,7 @@ impl ProfileV2Migration { updated_at: Utc::now(), raw_settings: Some(settings_value), raw_config_json: None, + source: ProfileSource::Custom, }; profiles.insert(profile_name.clone(), profile); tracing::info!("已从原始 Claude Code 配置迁移 Profile: {}", profile_name); @@ -320,6 +322,7 @@ impl ProfileV2Migration { updated_at: Utc::now(), raw_config_toml, raw_auth_json: Some(auth_data), + source: ProfileSource::Custom, }; profiles.insert(profile_name.clone(), profile); tracing::info!("已从原始 Codex 配置迁移 Profile: {}", profile_name); @@ -394,6 +397,7 @@ impl ProfileV2Migration { updated_at: Utc::now(), raw_settings: None, raw_env, + source: ProfileSource::Custom, }; profiles.insert(profile_name.clone(), profile); tracing::info!("已从原始 Gemini CLI 配置迁移 Profile: {}", profile_name); @@ -488,6 +492,7 @@ impl ProfileV2Migration { updated_at: descriptor.updated_at.unwrap_or_else(Utc::now), raw_settings, raw_config_json, + source: ProfileSource::Custom, }, CodexProfile::default_placeholder(), GeminiProfile::default_placeholder(), @@ -525,6 +530,7 @@ impl ProfileV2Migration { updated_at: descriptor.updated_at.unwrap_or_else(Utc::now), raw_config_toml, raw_auth_json, + source: ProfileSource::Custom, }, GeminiProfile::default_placeholder(), )) @@ -561,6 +567,7 @@ impl ProfileV2Migration { updated_at: descriptor.updated_at.unwrap_or_else(Utc::now), raw_settings, raw_env, + source: ProfileSource::Custom, }, )) } @@ -856,6 +863,7 @@ impl ClaudeProfile { updated_at: Utc::now(), raw_settings: None, raw_config_json: None, + source: ProfileSource::Custom, } } } @@ -870,6 +878,7 @@ impl CodexProfile { updated_at: Utc::now(), raw_config_toml: None, raw_auth_json: None, + source: ProfileSource::Custom, } } } @@ -884,6 +893,7 @@ impl GeminiProfile { updated_at: Utc::now(), raw_settings: None, raw_env: None, + source: ProfileSource::Custom, } } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 467c960..16bf614 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -9,11 +9,13 @@ // - migration_manager: 统一迁移管理(新) // - balance: 余额监控配置管理 // - provider_manager: 供应商配置管理 +// - new_api: NEW API 客户端服务 pub mod balance; pub mod config; pub mod dashboard_manager; // 仪表板状态管理 pub mod migration_manager; +pub mod new_api; // NEW API 客户端 pub mod profile_manager; // Profile管理(v2.1) pub mod provider_manager; // 供应商配置管理 pub mod proxy; @@ -27,9 +29,10 @@ pub use balance::*; pub use config::types::*; // 仅导出类型 pub use dashboard_manager::DashboardManager; pub use migration_manager::{create_migration_manager, MigrationManager}; +pub use new_api::NewApiClient; pub use profile_manager::{ ActiveStore, ClaudeProfile, CodexProfile, GeminiProfile, ProfileDescriptor, ProfileManager, - ProfilesStore, + ProfileSource, ProfilesStore, }; // Profile管理(v2.0) pub use provider_manager::ProviderManager; pub use proxy::*; diff --git a/src-tauri/src/services/new_api/client.rs b/src-tauri/src/services/new_api/client.rs new file mode 100644 index 0000000..7a34d87 --- /dev/null +++ b/src-tauri/src/services/new_api/client.rs @@ -0,0 +1,296 @@ +// NEW API Client +// +// NEW API 客户端服务,用于与供应商的 API 交互 + +use crate::models::provider::Provider; +use crate::models::remote_token::{ + CreateRemoteTokenRequest, NewApiResponse, RemoteToken, RemoteTokenGroup, RemoteTokenGroupInfo, + TokenListData, +}; +use anyhow::{anyhow, Result}; +use reqwest::Client; +use serde_json::json; +use std::collections::HashMap; +use std::time::Duration; + +/// NEW API 客户端 +pub struct NewApiClient { + provider: Provider, + client: Client, +} + +impl NewApiClient { + /// 创建新的 NEW API 客户端 + pub fn new(provider: Provider) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| anyhow!("创建 HTTP 客户端失败: {}", e))?; + + Ok(Self { provider, client }) + } + + /// 获取基础 URL + fn base_url(&self) -> String { + self.provider.website_url.trim_end_matches('/').to_string() + } + + /// 构建请求头 + fn build_headers(&self) -> reqwest::header::HeaderMap { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + "Authorization", + format!("Bearer {}", self.provider.access_token) + .parse() + .unwrap(), + ); + headers.insert("New-Api-User", self.provider.user_id.parse().unwrap()); + headers.insert("Content-Type", "application/json".parse().unwrap()); + headers + } + + /// 获取所有远程令牌列表 + pub async fn list_tokens(&self) -> Result> { + let url = format!("{}/api/token", self.base_url()); + let response = self + .client + .get(&url) + .headers(self.build_headers()) + .send() + .await + .map_err(|e| anyhow!("请求失败: {}", e))?; + + if !response.status().is_success() { + return Err(anyhow!( + "API 请求失败,状态码: {}", + response.status().as_u16() + )); + } + + let api_response: NewApiResponse = response + .json() + .await + .map_err(|e| anyhow!("解析响应失败: {}", e))?; + + if !api_response.success { + return Err(anyhow!( + "API 返回错误: {}", + api_response + .message + .unwrap_or_else(|| "未知错误".to_string()) + )); + } + + Ok(api_response.data.map(|d| d.items).unwrap_or_default()) + } + + /// 获取所有令牌分组 + pub async fn list_groups(&self) -> Result> { + let url = format!("{}/api/user/self/groups", self.base_url()); + let response = self + .client + .get(&url) + .headers(self.build_headers()) + .send() + .await + .map_err(|e| anyhow!("请求失败: {}", e))?; + + if !response.status().is_success() { + return Err(anyhow!( + "API 请求失败,状态码: {}", + response.status().as_u16() + )); + } + + let api_response: NewApiResponse> = response + .json() + .await + .map_err(|e| anyhow!("解析响应失败: {}", e))?; + + if !api_response.success { + return Err(anyhow!( + "API 返回错误: {}", + api_response + .message + .unwrap_or_else(|| "未知错误".to_string()) + )); + } + + // 将 HashMap 转换为 Vec + let groups = api_response + .data + .unwrap_or_default() + .into_iter() + .map(|(id, info)| RemoteTokenGroup { + id, + desc: info.desc, + ratio: info.ratio, + }) + .collect(); + + Ok(groups) + } + + /// 创建新的远程令牌 + pub async fn create_token(&self, request: CreateRemoteTokenRequest) -> Result { + let url = format!("{}/api/token", self.base_url()); + let body = json!({ + "name": request.name, + "group_id": request.group_id, + "quota": request.quota, + "expire_days": request.expire_days, + }); + + let response = self + .client + .post(&url) + .headers(self.build_headers()) + .json(&body) + .send() + .await + .map_err(|e| anyhow!("请求失败: {}", e))?; + + if !response.status().is_success() { + return Err(anyhow!( + "API 请求失败,状态码: {}", + response.status().as_u16() + )); + } + + let api_response: NewApiResponse = response + .json() + .await + .map_err(|e| anyhow!("解析响应失败: {}", e))?; + + if !api_response.success { + return Err(anyhow!( + "API 返回错误: {}", + api_response + .message + .unwrap_or_else(|| "未知错误".to_string()) + )); + } + + api_response + .data + .ok_or_else(|| anyhow!("API 未返回令牌数据")) + } + + /// 删除远程令牌 + pub async fn delete_token(&self, token_id: i64) -> Result<()> { + let url = format!("{}/api/token/{}", self.base_url(), token_id); + let response = self + .client + .delete(&url) + .headers(self.build_headers()) + .send() + .await + .map_err(|e| anyhow!("请求失败: {}", e))?; + + if !response.status().is_success() { + return Err(anyhow!( + "API 请求失败,状态码: {}", + response.status().as_u16() + )); + } + + let api_response: NewApiResponse<()> = response + .json() + .await + .map_err(|e| anyhow!("解析响应失败: {}", e))?; + + if !api_response.success { + return Err(anyhow!( + "API 返回错误: {}", + api_response + .message + .unwrap_or_else(|| "未知错误".to_string()) + )); + } + + Ok(()) + } + + /// 更新远程令牌信息 + pub async fn update_token(&self, token_id: i64, name: String) -> Result { + let url = format!("{}/api/token/{}", self.base_url(), token_id); + let body = json!({ + "name": name, + }); + + let response = self + .client + .patch(&url) + .headers(self.build_headers()) + .json(&body) + .send() + .await + .map_err(|e| anyhow!("请求失败: {}", e))?; + + if !response.status().is_success() { + return Err(anyhow!( + "API 请求失败,状态码: {}", + response.status().as_u16() + )); + } + + let api_response: NewApiResponse = response + .json() + .await + .map_err(|e| anyhow!("解析响应失败: {}", e))?; + + if !api_response.success { + return Err(anyhow!( + "API 返回错误: {}", + api_response + .message + .unwrap_or_else(|| "未知错误".to_string()) + )); + } + + api_response + .data + .ok_or_else(|| anyhow!("API 未返回令牌数据")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_creation() { + let provider = Provider { + id: "test".to_string(), + name: "Test Provider".to_string(), + website_url: "https://test.com".to_string(), + user_id: "123".to_string(), + access_token: "token123".to_string(), + username: None, + is_default: false, + created_at: 0, + updated_at: 0, + }; + + let client = NewApiClient::new(provider); + assert!(client.is_ok()); + } + + #[test] + fn test_base_url() { + let provider = Provider { + id: "test".to_string(), + name: "Test Provider".to_string(), + website_url: "https://test.com/".to_string(), + user_id: "123".to_string(), + access_token: "token123".to_string(), + username: None, + is_default: false, + created_at: 0, + updated_at: 0, + }; + + let client = NewApiClient::new(provider).unwrap(); + assert_eq!(client.base_url(), "https://test.com"); + } +} diff --git a/src-tauri/src/services/new_api/mod.rs b/src-tauri/src/services/new_api/mod.rs new file mode 100644 index 0000000..f14d29d --- /dev/null +++ b/src-tauri/src/services/new_api/mod.rs @@ -0,0 +1,7 @@ +// NEW API Service Module +// +// NEW API 服务模块 + +mod client; + +pub use client::NewApiClient; diff --git a/src-tauri/src/services/profile_manager/manager.rs b/src-tauri/src/services/profile_manager/manager.rs index bc41c74..1f0ca0a 100644 --- a/src-tauri/src/services/profile_manager/manager.rs +++ b/src-tauri/src/services/profile_manager/manager.rs @@ -41,7 +41,7 @@ impl ProfileManager { }) } - fn load_profiles_store(&self) -> Result { + pub fn load_profiles_store(&self) -> Result { if !self.profiles_path.exists() { return Ok(ProfilesStore::new()); } @@ -49,7 +49,7 @@ impl ProfileManager { serde_json::from_value(value).context("反序列化 ProfilesStore 失败") } - fn save_profiles_store(&self, store: &ProfilesStore) -> Result<()> { + pub fn save_profiles_store(&self, store: &ProfilesStore) -> Result<()> { // 创建锁文件(与 profiles.json 同目录) let lock_path = self.profiles_path.with_extension("lock"); let lock_file = File::create(&lock_path).context("创建锁文件失败")?; @@ -121,6 +121,7 @@ impl ProfileManager { updated_at: Utc::now(), raw_settings: None, raw_config_json: None, + source: ProfileSource::Custom, } }; @@ -206,6 +207,7 @@ impl ProfileManager { updated_at: Utc::now(), raw_config_toml: None, raw_auth_json: None, + source: ProfileSource::Custom, } }; @@ -293,6 +295,7 @@ impl ProfileManager { updated_at: Utc::now(), raw_settings: None, raw_env: None, + source: ProfileSource::Custom, } }; @@ -486,6 +489,7 @@ impl ProfileManager { updated_at: Utc::now(), raw_settings: None, raw_config_json: None, + source: ProfileSource::Custom, } }; @@ -533,6 +537,7 @@ impl ProfileManager { updated_at: Utc::now(), raw_config_toml: None, raw_auth_json: None, + source: ProfileSource::Custom, } }; @@ -582,6 +587,7 @@ impl ProfileManager { updated_at: Utc::now(), raw_settings: None, raw_env: None, + source: ProfileSource::Custom, } }; diff --git a/src-tauri/src/services/profile_manager/mod.rs b/src-tauri/src/services/profile_manager/mod.rs index e608e41..1f5289f 100644 --- a/src-tauri/src/services/profile_manager/mod.rs +++ b/src-tauri/src/services/profile_manager/mod.rs @@ -11,5 +11,5 @@ mod types; pub use manager::ProfileManager; pub use types::{ ActiveMetadata, ActiveProfile, ActiveStore, ClaudeProfile, CodexProfile, GeminiProfile, - ProfileDescriptor, ProfilesMetadata, ProfilesStore, + ProfileDescriptor, ProfileSource, ProfilesMetadata, ProfilesStore, }; diff --git a/src-tauri/src/services/profile_manager/types.rs b/src-tauri/src/services/profile_manager/types.rs index 7d413eb..7e7100e 100644 --- a/src-tauri/src/services/profile_manager/types.rs +++ b/src-tauri/src/services/profile_manager/types.rs @@ -6,6 +6,32 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +// ==================== Profile 来源标记 ==================== + +/// Profile 来源类型 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(tag = "type")] +pub enum ProfileSource { + /// 用户自定义创建 + #[default] + Custom, + /// 从供应商远程令牌导入 + ImportedFromProvider { + /// 供应商 ID + provider_id: String, + /// 供应商名称 + provider_name: String, + /// 远程令牌 ID + remote_token_id: i64, + /// 远程令牌名称 + remote_token_name: String, + /// 所属分组 + group: String, + /// 导入时间(Unix 时间戳) + imported_at: i64, + }, +} + // ==================== 具体 Profile 类型 ==================== /// Claude Code Profile @@ -13,6 +39,8 @@ use std::collections::HashMap; pub struct ClaudeProfile { pub api_key: String, pub base_url: String, + #[serde(default)] + pub source: ProfileSource, pub created_at: DateTime, pub updated_at: DateTime, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -28,6 +56,8 @@ pub struct CodexProfile { pub base_url: String, #[serde(default = "default_codex_wire_api")] pub wire_api: String, // "responses" 或 "chat" + #[serde(default)] + pub source: ProfileSource, pub created_at: DateTime, pub updated_at: DateTime, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -47,6 +77,8 @@ pub struct GeminiProfile { pub base_url: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, + #[serde(default)] + pub source: ProfileSource, pub created_at: DateTime, pub updated_at: DateTime, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -224,6 +256,7 @@ pub struct ProfileDescriptor { pub name: String, pub api_key_preview: String, pub base_url: String, + pub source: ProfileSource, pub created_at: DateTime, pub updated_at: DateTime, pub is_active: bool, @@ -253,6 +286,7 @@ impl ProfileDescriptor { name: name.to_string(), api_key_preview: mask_api_key(&profile.api_key), base_url: profile.base_url.clone(), + source: profile.source.clone(), created_at: profile.created_at, updated_at: profile.updated_at, is_active, @@ -279,6 +313,7 @@ impl ProfileDescriptor { name: name.to_string(), api_key_preview: mask_api_key(&profile.api_key), base_url: profile.base_url.clone(), + source: profile.source.clone(), created_at: profile.created_at, updated_at: profile.updated_at, is_active, @@ -305,6 +340,7 @@ impl ProfileDescriptor { name: name.to_string(), api_key_preview: mask_api_key(&profile.api_key), base_url: profile.base_url.clone(), + source: profile.source.clone(), created_at: profile.created_at, updated_at: profile.updated_at, is_active, diff --git a/src/lib/tauri-commands/token.ts b/src/lib/tauri-commands/token.ts new file mode 100644 index 0000000..0236ee1 --- /dev/null +++ b/src/lib/tauri-commands/token.ts @@ -0,0 +1,87 @@ +// Token Management Tauri Commands +// +// NEW API 令牌管理 Tauri 命令包装器 + +import { invoke } from '@tauri-apps/api/core'; +import type { Provider } from '@/types/provider'; +import type { CreateRemoteTokenRequest, RemoteToken, RemoteTokenGroup } from '@/types/remote-token'; + +/** + * 获取指定供应商的远程令牌列表 + */ +export async function fetchProviderTokens(provider: Provider): Promise { + return invoke('fetch_provider_tokens', { provider }); +} + +/** + * 获取指定供应商的令牌分组列表 + */ +export async function fetchProviderGroups(provider: Provider): Promise { + return invoke('fetch_provider_groups', { provider }); +} + +/** + * 在供应商创建新的远程令牌 + */ +export async function createProviderToken( + provider: Provider, + request: CreateRemoteTokenRequest, +): Promise { + return invoke('create_provider_token', { provider, request }); +} + +/** + * 删除供应商的远程令牌 + */ +export async function deleteProviderToken(provider: Provider, tokenId: number): Promise { + return invoke('delete_provider_token', { provider, tokenId }); +} + +/** + * 更新供应商的远程令牌名称 + */ +export async function updateProviderToken( + provider: Provider, + tokenId: number, + name: string, +): Promise { + return invoke('update_provider_token', { provider, tokenId, name }); +} + +/** + * 导入远程令牌为本地 Profile + */ +export async function importTokenAsProfile( + provider: Provider, + remoteToken: RemoteToken, + toolId: string, + profileName: string, +): Promise { + return invoke('import_token_as_profile', { + profileManager: null, // Managed by Tauri State + provider, + remoteToken, + toolId, + profileName, + }); +} + +/** + * 创建自定义 Profile(非导入令牌) + */ +export async function createCustomProfile( + toolId: string, + profileName: string, + apiKey: string, + baseUrl: string, + extraConfig?: { wire_api?: string; model?: string }, +): Promise { + return invoke('create_custom_profile', { + profileManager: null, // Managed by Tauri State + toolId, + profileName, + apiKey, + baseUrl, + extraConfig: extraConfig || null, + }); +} diff --git a/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx b/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx index b0d6339..5aa4b89 100644 --- a/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx +++ b/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx @@ -338,6 +338,14 @@ export function ActiveProfileCard({ group, proxyRunning }: ActiveProfileCardProp
+ {selectedInstance && ( <> void; + toolId: ToolId; + onSuccess: () => void; +} + +/** + * 从供应商导入 Profile 对话框 + */ +export function ImportFromProviderDialog({ + open, + onOpenChange, + toolId, + onSuccess, +}: ImportFromProviderDialogProps) { + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + const [importing, setImporting] = useState(false); + + // 供应商和令牌数据 + const [providers, setProviders] = useState([]); + const [tokens, setTokens] = useState([]); + + // 表单数据 + const [selectedProviderId, setSelectedProviderId] = useState(''); + const [selectedTokenId, setSelectedTokenId] = useState(null); + const [profileName, setProfileName] = useState(''); + + // 获取当前选中的供应商和令牌 + const selectedProvider = providers.find((p) => p.id === selectedProviderId); + const selectedToken = tokens.find((t) => t.id === selectedTokenId); + + /** + * 加载供应商列表 + */ + const loadProviders = async () => { + try { + setLoading(true); + const result = await listProviders(); + setProviders(result); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + toast({ + title: '加载供应商失败', + description: errorMsg, + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + /** + * 加载选中供应商的令牌列表 + */ + const loadTokens = async (provider: Provider) => { + try { + setLoading(true); + const result = await fetchProviderTokens(provider); + setTokens(result); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + toast({ + title: '加载令牌失败', + description: errorMsg, + variant: 'destructive', + }); + setTokens([]); + } finally { + setLoading(false); + } + }; + + /** + * Dialog 打开时加载供应商列表 + */ + useEffect(() => { + if (open) { + loadProviders(); + // 重置表单 + setSelectedProviderId(''); + setSelectedTokenId(null); + setProfileName(''); + setTokens([]); + } + }, [open]); + + /** + * 供应商变更时加载令牌列表 + */ + useEffect(() => { + if (selectedProvider) { + loadTokens(selectedProvider); + setSelectedTokenId(null); + } else { + setTokens([]); + setSelectedTokenId(null); + } + }, [selectedProviderId]); + + /** + * 令牌变更时自动填充 Profile 名称 + */ + useEffect(() => { + if (selectedToken && !profileName) { + setProfileName(selectedToken.name + '_profile'); + } + }, [selectedTokenId]); + + /** + * 提交导入 + */ + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!selectedProvider || !selectedToken) { + toast({ + title: '请选择供应商和令牌', + variant: 'destructive', + }); + return; + } + + if (!profileName.trim()) { + toast({ + title: '请输入 Profile 名称', + variant: 'destructive', + }); + return; + } + + // 检查保留前缀 + if (profileName.startsWith('dc_proxy_')) { + toast({ + title: '验证失败', + description: 'Profile 名称不能以 dc_proxy_ 开头(系统保留)', + variant: 'destructive', + }); + return; + } + + setImporting(true); + try { + // 检查是否已存在同名 Profile + const existingProfiles = await pmListToolProfiles(toolId); + if (existingProfiles.includes(profileName)) { + toast({ + title: '验证失败', + description: '该 Profile 名称已存在,请使用其他名称', + variant: 'destructive', + }); + setImporting(false); + return; + } + + await importTokenAsProfile(selectedProvider, selectedToken, toolId, profileName); + toast({ + title: '导入成功', + description: + '令牌「' + selectedToken.name + '」已成功导入为 Profile「' + profileName + '」', + }); + onSuccess(); + onOpenChange(false); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + toast({ + title: '导入失败', + description: errorMsg, + variant: 'destructive', + }); + } finally { + setImporting(false); + } + }; + + return ( + + + + 从供应商导入 Profile + 选择供应商和令牌,一键导入为本地 Profile 配置 + + +
+ {/* 选择供应商 */} +
+ + +

选择要从哪个供应商导入令牌

+
+ + {/* 选择令牌 */} +
+ + +

选择要导入的令牌

+
+ + {/* 令牌信息预览 */} + {selectedToken && ( +
+
+ 令牌名称: + {selectedToken.name} +
+
+ 分组: + {selectedToken.group} +
+
+ 剩余额度: + + {selectedToken.unlimited_quota + ? '无限' + : '$' + (selectedToken.remain_quota / 1000000).toFixed(2)} + +
+
+ )} + + {/* Profile 名称 */} +
+ + setProfileName(e.target.value)} + placeholder="例如:my_api_profile" + required + /> +

为导入的 Profile 设置一个本地名称

+
+ + + + + +
+
+
+ ); +} diff --git a/src/pages/ProfileManagementPage/components/ProfileCard.tsx b/src/pages/ProfileManagementPage/components/ProfileCard.tsx index a74e6cd..73fdc25 100644 --- a/src/pages/ProfileManagementPage/components/ProfileCard.tsx +++ b/src/pages/ProfileManagementPage/components/ProfileCard.tsx @@ -3,7 +3,7 @@ */ import { useState } from 'react'; -import { Check, MoreVertical, Pencil, Power, Trash2 } from 'lucide-react'; +import { Check, MoreVertical, Pencil, Power, Trash2, Tag } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { @@ -61,6 +61,36 @@ export function ProfileCard({ } }; + /** + * 获取来源显示文本和样式 + */ + const getSourceInfo = () => { + if (profile.source.type === 'Custom') { + return { + text: '自定义', + variant: 'secondary' as const, + tooltip: '用户手动创建的 Profile', + }; + } else { + const importedAt = new Date(profile.source.imported_at * 1000); + return { + text: '从 ' + profile.source.provider_name + ' 导入', + variant: 'outline' as const, + tooltip: + '从供应商「' + + profile.source.provider_name + + '」的令牌「' + + profile.source.remote_token_name + + '」导入\n分组: ' + + profile.source.group + + '\n导入时间: ' + + importedAt.toLocaleString('zh-CN'), + }; + } + }; + + const sourceInfo = getSourceInfo(); + return ( <> @@ -74,6 +104,10 @@ export function ProfileCard({ 激活中 )} + + + {sourceInfo.text} +
API Key: {profile.api_key_preview} diff --git a/src/pages/ProfileManagementPage/index.tsx b/src/pages/ProfileManagementPage/index.tsx index 84a9bd9..dc4f571 100644 --- a/src/pages/ProfileManagementPage/index.tsx +++ b/src/pages/ProfileManagementPage/index.tsx @@ -3,9 +3,15 @@ */ import { useState, useEffect } from 'react'; -import { RefreshCw, Loader2, HelpCircle } from 'lucide-react'; +import { RefreshCw, Loader2, HelpCircle, Plus, Download, ChevronDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, @@ -17,6 +23,7 @@ import { PageContainer } from '@/components/layout/PageContainer'; import { ProfileCard } from './components/ProfileCard'; import { ProfileEditor } from './components/ProfileEditor'; import { ActiveProfileCard } from './components/ActiveProfileCard'; +import { ImportFromProviderDialog } from './components/ImportFromProviderDialog'; import { useProfileManagement } from './hooks/useProfileManagement'; import type { ToolId, ProfileFormData, ProfileDescriptor } from '@/types/profile'; import { logoMap } from '@/utils/constants'; @@ -40,6 +47,7 @@ export default function ProfileManagementPage() { const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create'); const [editingProfile, setEditingProfile] = useState(null); const [helpDialogOpen, setHelpDialogOpen] = useState(false); + const [importDialogOpen, setImportDialogOpen] = useState(false); // 初始化加载透明代理状态 useEffect(() => { @@ -167,13 +175,24 @@ export default function ProfileManagementPage() { {group.active_profile && ` · 当前激活: ${group.active_profile.name}`}

- + + + + + + + + 手动创建 + + setImportDialogOpen(true)}> + + 从供应商导入 + + + {/* Profile 卡片列表 */} @@ -213,6 +232,17 @@ export default function ProfileManagementPage() { {/* 帮助弹窗 */} + + {/* 从供应商导入对话框 */} + { + setImportDialogOpen(false); + refresh(); + }} + /> ); } diff --git a/src/pages/ProviderManagementPage/components/CreateRemoteTokenDialog.tsx b/src/pages/ProviderManagementPage/components/CreateRemoteTokenDialog.tsx new file mode 100644 index 0000000..468f4e4 --- /dev/null +++ b/src/pages/ProviderManagementPage/components/CreateRemoteTokenDialog.tsx @@ -0,0 +1,310 @@ +// Create Remote Token Dialog +// +// 创建远程令牌对话框 + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Loader2 } from 'lucide-react'; +import type { Provider } from '@/types/provider'; +import type { RemoteTokenGroup, CreateRemoteTokenRequest } from '@/types/remote-token'; +import { fetchProviderGroups, createProviderToken } from '@/lib/tauri-commands/token'; +import { useToast } from '@/hooks/use-toast'; + +interface CreateRemoteTokenDialogProps { + provider: Provider; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +/** + * 创建远程令牌对话框 + */ +export function CreateRemoteTokenDialog({ + provider, + open, + onOpenChange, + onSuccess, +}: CreateRemoteTokenDialogProps) { + const { toast } = useToast(); + const [groups, setGroups] = useState([]); + const [loadingGroups, setLoadingGroups] = useState(false); + const [creating, setCreating] = useState(false); + + const [formData, setFormData] = useState({ + name: '', + group_id: '', + quota: -1, // 默认无限额度 (-1 表示无限) + expire_days: 0, // 默认永不过期 (0 表示永不过期) + }); + + const [unlimitedQuota, setUnlimitedQuota] = useState(true); // 默认勾选无限额度 + const [unlimitedExpire, setUnlimitedExpire] = useState(false); // 默认不勾选无限时长 + + /** + * 加载分组列表 + */ + const loadGroups = async () => { + setLoadingGroups(true); + try { + const result = await fetchProviderGroups(provider); + setGroups(result); + // 如果有分组,默认选择第一个 + if (result.length > 0 && !formData.group_id) { + setFormData((prev) => ({ ...prev, group_id: result[0].id })); + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + toast({ + title: '加载分组失败', + description: errorMsg, + variant: 'destructive', + }); + } finally { + setLoadingGroups(false); + } + }; + + /** + * 提交创建 + */ + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // 验证表单 + if (!formData.name.trim()) { + toast({ + title: '验证失败', + description: '请输入令牌名称', + variant: 'destructive', + }); + return; + } + + if (!formData.group_id) { + toast({ + title: '验证失败', + description: '请选择分组', + variant: 'destructive', + }); + return; + } + + setCreating(true); + try { + await createProviderToken(provider, formData); + toast({ + title: '令牌已创建', + description: `令牌「${formData.name}」已成功创建`, + }); + onSuccess(); + onOpenChange(false); + // 重置表单 + setFormData({ + name: '', + group_id: groups.length > 0 ? groups[0].id : '', + quota: -1, + expire_days: 0, + }); + setUnlimitedQuota(true); + setUnlimitedExpire(false); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + toast({ + title: '创建失败', + description: errorMsg, + variant: 'destructive', + }); + } finally { + setCreating(false); + } + }; + + /** + * 对话框打开时加载分组 + */ + useEffect(() => { + if (open) { + loadGroups(); + } + }, [open]); + + return ( + + + + 创建远程令牌 + 在供应商「{provider.name}」创建新的 API 令牌 + + +
+ {/* 令牌名称 */} +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="例如:Production API Key" + required + /> +
+ + {/* 分组 */} +
+ + {loadingGroups ? ( +
+ + 加载分组... +
+ ) : ( + + )} +
+ + {/* 额度 */} +
+
+ +
+ { + setUnlimitedQuota(checked === true); + if (checked) { + setFormData({ ...formData, quota: -1 }); + } else { + setFormData({ ...formData, quota: 100000 }); // 默认 0.1 美元 + } + }} + /> + +
+
+ + setFormData({ + ...formData, + quota: Math.round(parseFloat(e.target.value || '0') * 1000000), + }) + } + placeholder="0.10" + disabled={unlimitedQuota} + /> +

+ {unlimitedQuota ? '令牌将拥有无限额度' : '设置令牌的初始额度限制'} +

+
+ + {/* 有效期 */} +
+
+ +
+ { + setUnlimitedExpire(checked === true); + if (checked) { + setFormData({ ...formData, expire_days: 0 }); + } else { + setFormData({ ...formData, expire_days: 30 }); // 默认 30 天 + } + }} + /> + +
+
+ + setFormData({ + ...formData, + expire_days: parseInt(e.target.value || '30', 10), + }) + } + placeholder="30" + disabled={unlimitedExpire} + /> +

+ {unlimitedExpire ? '令牌将永不过期' : '设置令牌的有效期天数'} +

+
+ + + + + +
+
+
+ ); +} diff --git a/src/pages/ProviderManagementPage/components/ImportTokenDialog.tsx b/src/pages/ProviderManagementPage/components/ImportTokenDialog.tsx new file mode 100644 index 0000000..f037667 --- /dev/null +++ b/src/pages/ProviderManagementPage/components/ImportTokenDialog.tsx @@ -0,0 +1,199 @@ +// Import Token Dialog +// +// 导入令牌为 Profile 对话框 + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Loader2 } from 'lucide-react'; +import type { Provider } from '@/types/provider'; +import type { RemoteToken } from '@/types/remote-token'; +import { importTokenAsProfile } from '@/lib/tauri-commands/token'; +import { pmListToolProfiles } from '@/lib/tauri-commands/profile'; +import type { ToolId } from '@/lib/tauri-commands/types'; +import { useToast } from '@/hooks/use-toast'; + +interface ImportTokenDialogProps { + provider: Provider; + token: RemoteToken; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +const TOOL_OPTIONS = [ + { id: 'claude-code', name: 'Claude Code' }, + { id: 'codex', name: 'Codex' }, + { id: 'gemini-cli', name: 'Gemini CLI' }, +]; + +/** + * 导入令牌为 Profile 对话框 + */ +export function ImportTokenDialog({ + provider, + token, + open, + onOpenChange, + onSuccess, +}: ImportTokenDialogProps) { + const { toast } = useToast(); + const [importing, setImporting] = useState(false); + const [toolId, setToolId] = useState('claude-code'); + const [profileName, setProfileName] = useState(''); + + /** + * 提交导入 + */ + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // 验证表单 + if (!profileName.trim()) { + toast({ + title: '验证失败', + description: '请输入 Profile 名称', + variant: 'destructive', + }); + return; + } + + // 检查保留前缀 + if (profileName.startsWith('dc_proxy_')) { + toast({ + title: '验证失败', + description: 'Profile 名称不能以 dc_proxy_ 开头(系统保留)', + variant: 'destructive', + }); + return; + } + + setImporting(true); + try { + // 检查是否已存在同名 Profile + const existingProfiles = await pmListToolProfiles(toolId as ToolId); + if (existingProfiles.includes(profileName)) { + const toolName = TOOL_OPTIONS.find((t) => t.id === toolId)?.name || toolId; + toast({ + title: '验证失败', + description: `Profile「${profileName}」已存在于 ${toolName} 中,请使用其他名称`, + variant: 'destructive', + }); + setImporting(false); + return; + } + + await importTokenAsProfile(provider, token, toolId, profileName); + toast({ + title: '导入成功', + description: `令牌「${token.name}」已成功导入为 Profile「${profileName}」`, + }); + onSuccess(); + // 重置表单 + setProfileName(''); + setToolId('claude-code'); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + toast({ + title: '导入失败', + description: errorMsg, + variant: 'destructive', + }); + } finally { + setImporting(false); + } + }; + + return ( + + + + 导入令牌为 Profile + 将令牌「{token.name}」导入为本地 Profile 配置 + + +
+ {/* 令牌信息 */} +
+
+ 令牌名称: + {token.name} +
+
+ 分组: + {token.group} +
+
+ 剩余额度: + + {token.unlimited_quota ? '无限' : `$${(token.remain_quota / 1000000).toFixed(2)}`} + +
+
+ + {/* 选择工具 */} +
+ + +

选择要导入到哪个工具的 Profile 配置

+
+ + {/* Profile 名称 */} +
+ + setProfileName(e.target.value)} + placeholder={`例如:${token.name}_profile`} + required + /> +

为导入的 Profile 设置一个本地名称

+
+ + + + + +
+
+
+ ); +} diff --git a/src/pages/ProviderManagementPage/components/RemoteTokenManagement.tsx b/src/pages/ProviderManagementPage/components/RemoteTokenManagement.tsx new file mode 100644 index 0000000..84fb442 --- /dev/null +++ b/src/pages/ProviderManagementPage/components/RemoteTokenManagement.tsx @@ -0,0 +1,239 @@ +// Remote Token Management Component +// +// 远程令牌管理组件 - 显示和管理供应商的远程令牌 + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Loader2, Plus, Download, Trash2, RefreshCw } from 'lucide-react'; +import type { Provider } from '@/types/provider'; +import type { RemoteToken } from '@/types/remote-token'; +import { TOKEN_STATUS_TEXT, TOKEN_STATUS_VARIANT, TokenStatus } from '@/types/remote-token'; +import { fetchProviderTokens, deleteProviderToken } from '@/lib/tauri-commands/token'; +import { useToast } from '@/hooks/use-toast'; +import { CreateRemoteTokenDialog } from './CreateRemoteTokenDialog'; +import { ImportTokenDialog } from './ImportTokenDialog'; + +interface RemoteTokenManagementProps { + provider: Provider; +} + +/** + * 远程令牌管理组件 + */ +export function RemoteTokenManagement({ provider }: RemoteTokenManagementProps) { + const { toast } = useToast(); + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [selectedToken, setSelectedToken] = useState(null); + + /** + * 加载令牌列表 + */ + const loadTokens = async () => { + setLoading(true); + setError(null); + try { + const result = await fetchProviderTokens(provider); + setTokens(result); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + setError(errorMsg); + toast({ + title: '加载失败', + description: errorMsg, + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + /** + * 删除令牌 + */ + const handleDelete = async (tokenId: number, tokenName: string) => { + try { + await deleteProviderToken(provider, tokenId); + toast({ + title: '令牌已删除', + description: `令牌「${tokenName}」已成功删除`, + }); + await loadTokens(); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + toast({ + title: '删除失败', + description: errorMsg, + variant: 'destructive', + }); + } + }; + + /** + * 打开导入对话框 + */ + const handleImport = (token: RemoteToken) => { + setSelectedToken(token); + setImportDialogOpen(true); + }; + + /** + * 格式化时间戳 + */ + const formatTimestamp = (timestamp: number) => { + if (timestamp === -1 || timestamp === 0) return '永不过期'; + return new Date(timestamp * 1000).toLocaleString('zh-CN'); + }; + + /** + * 格式化额度 + */ + const formatQuota = (quota: number, unlimited: boolean) => { + if (unlimited) return '无限'; + return `$${(quota / 1000000).toFixed(2)}`; + }; + + /** + * 组件加载时获取令牌列表 + */ + useEffect(() => { + loadTokens(); + }, [provider.id]); + + return ( +
+ {/* 操作栏 */} +
+

远程令牌

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

{error}

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

暂无令牌,请点击「创建令牌」按钮添加

+
+ ) : ( +
+ + + + 名称 + 分组 + 剩余额度 + 状态 + 过期时间 + 操作 + + + + {tokens.map((token) => ( + + {/* 名称 */} + {token.name} + + {/* 分组 */} + {token.group} + + {/* 剩余额度 */} + + {formatQuota(token.remain_quota, token.unlimited_quota)} + + + {/* 状态 */} + + + {TOKEN_STATUS_TEXT[token.status as TokenStatus]} + + + + {/* 过期时间 */} + + {formatTimestamp(token.expired_time)} + + + {/* 操作 */} + +
+ + +
+
+
+ ))} +
+
+
+ )} + + {/* 创建令牌对话框 */} + + + {/* 导入令牌对话框 */} + {selectedToken && ( + { + setImportDialogOpen(false); + setSelectedToken(null); + }} + /> + )} +
+ ); +} diff --git a/src/pages/ProviderManagementPage/index.tsx b/src/pages/ProviderManagementPage/index.tsx index 3978b02..c6409db 100644 --- a/src/pages/ProviderManagementPage/index.tsx +++ b/src/pages/ProviderManagementPage/index.tsx @@ -9,13 +9,14 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import { Building2, Plus, Pencil, Trash2, Loader2 } from 'lucide-react'; +import { Building2, Plus, Pencil, Trash2, Loader2, ChevronDown, ChevronRight } 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'; +import { RemoteTokenManagement } from './components/RemoteTokenManagement'; /** * 供应商管理页面 @@ -31,6 +32,7 @@ export function ProviderManagementPage() { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deletingProvider, setDeletingProvider] = useState(null); const [deleting, setDeleting] = useState(false); + const [expandedProviderId, setExpandedProviderId] = useState(null); /** * 打开新增对话框 @@ -102,6 +104,13 @@ export function ProviderManagementPage() { return new Date(timestamp * 1000).toLocaleString('zh-CN'); }; + /** + * 切换展开/折叠 + */ + const toggleExpand = (providerId: string) => { + setExpandedProviderId((prev) => (prev === providerId ? null : providerId)); + }; + return (
@@ -141,6 +150,7 @@ export function ProviderManagementPage() { + 名称 官网地址 用户名 @@ -150,51 +160,79 @@ export function ProviderManagementPage() { {providers.map((provider) => { + const isExpanded = expandedProviderId === provider.id; return ( - - {/* 名称 */} - {provider.name} - - {/* 官网地址 */} - - - {provider.website_url} - - - - {/* 用户名 */} - {provider.username || '-'} - - {/* 更新时间 */} - - {formatTimestamp(provider.updated_at)} - - - {/* 操作 */} - -
- + <> + + {/* 展开按钮 */} + -
-
-
+ + + {/* 名称 */} + {provider.name} + + {/* 官网地址 */} + + + {provider.website_url} + + + + {/* 用户名 */} + {provider.username || '-'} + + {/* 更新时间 */} + + {formatTimestamp(provider.updated_at)} + + + {/* 操作 */} + +
+ + +
+
+ + + {/* 展开内容:令牌管理 */} + {isExpanded && ( + + + + + + )} + ); })}
diff --git a/src/types/profile.ts b/src/types/profile.ts index 59e1a8a..c6e8437 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -27,7 +27,7 @@ export interface CodexProfilePayload { export interface GeminiProfilePayload { api_key: string; base_url: string; - model?: string; // 可选,不填则不修改原生配置 + model?: string; // 可选,不填则不修改原生配置 } /** @@ -58,6 +58,21 @@ export interface ProfileData { raw_env?: string; } +/** + * Profile 来源类型 + */ +export type ProfileSource = + | { type: 'Custom' } + | { + type: 'ImportedFromProvider'; + provider_id: string; + provider_name: string; + remote_token_id: number; + remote_token_name: string; + group: string; + imported_at: number; // Unix 时间戳 + }; + /** * Profile 描述符(前端展示用) */ @@ -66,11 +81,12 @@ export interface ProfileDescriptor { name: string; api_key_preview: string; // 脱敏显示(如 "sk-ant-***xxx") base_url: string; + source: ProfileSource; // Profile 来源信息 created_at: string; // ISO 8601 时间字符串 updated_at: string; // ISO 8601 时间字符串 is_active: boolean; switched_at?: string; // 激活时间(ISO 8601 时间字符串) - // Codex 特定字段(注意:后端是 wire_api,前端展示用 provider 兼容) + // Codex 特定字段(注意:后端是 wire_api,前端展示用 provider 兼容) wire_api?: string; provider?: string; // 向后兼容 // Gemini 特定字段 diff --git a/src/types/remote-token.ts b/src/types/remote-token.ts new file mode 100644 index 0000000..7fbfac6 --- /dev/null +++ b/src/types/remote-token.ts @@ -0,0 +1,101 @@ +// Remote Token Types +// +// NEW API 远程令牌类型定义 + +/** + * 远程令牌 + */ +export interface RemoteToken { + id: number; + user_id: number; + name: string; + key: string; + group: string; + remain_quota: number; + used_quota: number; + expired_time: number; + status: number; + unlimited_quota: boolean; + model_limits_enabled: boolean; + model_limits: string; + allow_ips: string; + cross_group_retry: boolean; + created_time: number; + accessed_time: number; +} + +/** + * 远程令牌分组 + */ +export interface RemoteTokenGroup { + id: string; + desc: string; + ratio: number; +} + +/** + * 创建远程令牌请求 + */ +export interface CreateRemoteTokenRequest { + name: string; + group_id: string; + quota: number; + expire_days: number; +} + +/** + * 导入令牌为 Profile 请求 + */ +export interface ImportTokenAsProfileRequest { + provider_id: string; + remote_token: RemoteToken; + tool_id: string; + profile_name: string; +} + +/** + * 创建自定义 Profile 请求 + */ +export interface CreateCustomProfileRequest { + tool_id: string; + profile_name: string; + api_key: string; + base_url: string; + extra_config?: { + wire_api?: string; // Codex specific + model?: string; // Gemini specific + }; +} + +/** + * 令牌状态枚举 + */ +export enum TokenStatus { + Enabled = 1, + Disabled = 2, + Expired = 3, + Exhausted = 4, +} + +/** + * 令牌状态文本映射 + */ +export const TOKEN_STATUS_TEXT: Record = { + [TokenStatus.Enabled]: '启用', + [TokenStatus.Disabled]: '禁用', + [TokenStatus.Expired]: '已过期', + [TokenStatus.Exhausted]: '已用尽', +}; + +/** + * 令牌状态颜色映射(用于 Badge) + */ +export const TOKEN_STATUS_VARIANT: Record< + TokenStatus, + 'default' | 'secondary' | 'destructive' | 'outline' +> = { + [TokenStatus.Enabled]: 'default', + [TokenStatus.Disabled]: 'secondary', + [TokenStatus.Expired]: 'destructive', + [TokenStatus.Exhausted]: 'outline', +}; From 84f4812c3a0b183d8e2ce782991a3042391fbe3f Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:56:15 +0800 Subject: [PATCH 02/10] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E5=A4=87=E4=BB=BD=E6=96=87=E4=BB=B6=20temp=5Fold=5Fma?= =?UTF-8?q?in.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 清理代码重构过程中遗留的临时备份文件 - 该文件为旧版 main.rs 的副本,已完成模块化拆分 - 保持代码库整洁 --- temp_old_main.rs | 2256 ---------------------------------------------- 1 file changed, 2256 deletions(-) delete mode 100644 temp_old_main.rs diff --git a/temp_old_main.rs b/temp_old_main.rs deleted file mode 100644 index 835043b..0000000 --- a/temp_old_main.rs +++ /dev/null @@ -1,2256 +0,0 @@ -// Prevents additional console window on Windows in release, DO NOT REMOVE!! -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -use serde::Serialize; -use serde_json::Value; -use std::env; -use std::fs; -use std::path::PathBuf; -use std::process::Command; -use tauri::{ - menu::{Menu, MenuItem, PredefinedMenuItem}, - tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Emitter, Manager, Runtime, WebviewWindow, -}; - -// 导入服务层 -use duckcoding::models::update::{PackageFormatInfo, PlatformInfo}; -use duckcoding::{ - services::config::{CodexSettingsPayload, GeminiEnvPayload, GeminiSettingsPayload}, - ConfigService, InstallMethod, InstallerService, Tool, UpdateInfo, UpdateService, UpdateStatus, - VersionService, -}; -// Use the shared GlobalConfig from the library crate (models::config) -use duckcoding::GlobalConfig; -// 导入透明代理服务 -use duckcoding::{ProxyConfig, TransparentProxyConfigService, TransparentProxyService}; -use std::sync::Arc; -use tokio::sync::Mutex as TokioMutex; - -// DuckCoding API 响应结构 -#[derive(serde::Deserialize, Debug)] -struct TokenData { - id: i64, - key: String, - #[allow(dead_code)] - name: String, - #[allow(dead_code)] - group: String, -} - -#[derive(serde::Deserialize, Debug)] -struct ApiResponse { - success: bool, - message: String, - data: Option>, -} - -#[derive(serde::Serialize)] -struct GenerateApiKeyResult { - success: bool, - message: String, - api_key: Option, -} - -// 用量统计数据结构 -#[derive(serde::Deserialize, Serialize, Debug, Clone)] -struct UsageData { - id: i64, - user_id: i64, - username: String, - model_name: String, - created_at: i64, - token_used: i64, - count: i64, - quota: i64, -} - -#[derive(serde::Deserialize, Debug)] -struct UsageApiResponse { - success: bool, - message: String, - data: Option>, -} - -#[derive(serde::Serialize)] -struct UsageStatsResult { - success: bool, - message: String, - data: Vec, -} - -// 用户信息数据结构 -#[derive(serde::Deserialize, Serialize, Debug)] -struct UserInfo { - id: i64, - username: String, - quota: i64, - used_quota: i64, - request_count: i64, -} - -#[derive(serde::Deserialize, Debug)] -struct UserApiResponse { - success: bool, - message: String, - data: Option, -} - -#[derive(serde::Serialize)] -struct UserQuotaResult { - success: bool, - message: String, - total_quota: f64, - used_quota: f64, - remaining_quota: f64, - request_count: i64, -} - -// Windows特定:隐藏命令行窗口 -#[cfg(target_os = "windows")] -use std::os::windows::process::CommandExt; - -const CLOSE_CONFIRM_EVENT: &str = "duckcoding://request-close-action"; -const SINGLE_INSTANCE_EVENT: &str = "single-instance"; - -#[derive(Clone, Serialize)] -struct SingleInstancePayload { - args: Vec, - cwd: String, -} - -// 辅助函数:获取扩展的PATH环境变量 -fn get_extended_path() -> String { - #[cfg(target_os = "windows")] - { - let user_profile = - env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\Default".to_string()); - - let mut system_paths = vec![ - // Claude Code 可能的安装路径 - format!("{}\\AppData\\Local\\Programs\\claude-code", user_profile), - format!("{}\\AppData\\Roaming\\npm", user_profile), - format!( - "{}\\AppData\\Local\\Programs\\Python\\Python312", - user_profile - ), - format!( - "{}\\AppData\\Local\\Programs\\Python\\Python312\\Scripts", - user_profile - ), - // 常见安装路径 - "C:\\Program Files\\nodejs".to_string(), - "C:\\Program Files\\Git\\cmd".to_string(), - // 系统路径 - "C:\\Windows\\System32".to_string(), - "C:\\Windows".to_string(), - ]; - - // nvm-windows支持 - if let Ok(nvm_home) = env::var("NVM_HOME") { - system_paths.insert(0, format!("{}\\current", nvm_home)); - } - - let current_path = env::var("PATH").unwrap_or_default(); - format!("{};{}", system_paths.join(";"), current_path) - } - - #[cfg(not(target_os = "windows"))] - { - let home_dir = env::var("HOME").unwrap_or_else(|_| "/Users/default".to_string()); - - let mut system_paths = vec![ - // Claude Code 可能的安装路径 - format!("{}/.local/bin", home_dir), - format!("{}/.claude/bin", home_dir), - format!("{}/.claude/local", home_dir), // Claude Code local安装 - // Homebrew - "/opt/homebrew/bin".to_string(), - "/usr/local/bin".to_string(), - // 系统路径 - "/usr/bin".to_string(), - "/bin".to_string(), - "/usr/sbin".to_string(), - "/sbin".to_string(), - ]; - - // nvm支持 - 优先使用当前激活的版本 - if let Ok(nvm_dir) = env::var("NVM_DIR") { - // 检查nvm current symlink - let nvm_current = format!("{}/current/bin", nvm_dir); - if std::path::Path::new(&nvm_current).exists() { - system_paths.insert(0, nvm_current); - } else { - // 如果没有current symlink,尝试读取.nvmrc或使用default - let nvm_default = format!("{}/.nvm/versions/node/default/bin", home_dir); - if std::path::Path::new(&nvm_default).exists() { - system_paths.insert(0, nvm_default); - } - } - } else { - // 如果NVM_DIR未设置,尝试默认路径 - let nvm_current = format!("{}/.nvm/current/bin", home_dir); - if std::path::Path::new(&nvm_current).exists() { - system_paths.insert(0, nvm_current); - } - } - - format!( - "{}:{}", - system_paths.join(":"), - env::var("PATH").unwrap_or_default() - ) - } -} - -//定义 Tauri Commands -#[tauri::command] -async fn check_installations() -> Result, String> { - let installer = InstallerService::new(); - let mut result = Vec::new(); - - for tool in Tool::all() { - let installed = installer.is_installed(&tool).await; - let version = if installed { - installer.get_installed_version(&tool).await - } else { - None - }; - - result.push(ToolStatus { - id: tool.id.clone(), - name: tool.name.clone(), - installed, - version, - }); - } - - Ok(result) -} - -// 检测node环境 -#[tauri::command] -async fn check_node_environment() -> Result { - let run_command = |cmd: &str| -> Result { - #[cfg(target_os = "windows")] - { - Command::new("cmd") - .env("PATH", get_extended_path()) - .arg("/C") - .arg(cmd) - .creation_flags(0x08000000) // CREATE_NO_WINDOW - 隐藏终端窗口 - .output() - } - #[cfg(not(target_os = "windows"))] - { - Command::new("sh") - .env("PATH", get_extended_path()) - .arg("-c") - .arg(cmd) - .output() - } - }; - - // 检测node - let (node_available, node_version) = if let Ok(output) = run_command("node --version 2>&1") { - if output.status.success() { - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - (true, Some(version)) - } else { - (false, None) - } - } else { - (false, None) - }; - - // 检测npm - let (npm_available, npm_version) = if let Ok(output) = run_command("npm --version 2>&1") { - if output.status.success() { - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - (true, Some(version)) - } else { - (false, None) - } - } else { - (false, None) - }; - - Ok(NodeEnvironment { - node_available, - node_version, - npm_available, - npm_version, - }) -} - -// 辅助函数:从全局配置应用代理 -async fn apply_proxy_if_configured() { - if let Ok(Some(config)) = get_global_config().await { - duckcoding::ProxyService::apply_proxy_from_config(&config); - } -} - -#[tauri::command] -async fn install_tool( - tool: String, - method: String, - force: Option, -) -> Result { - // 应用代理配置(如果已配置) - apply_proxy_if_configured().await; - - let force = force.unwrap_or(false); - #[cfg(debug_assertions)] - println!( - "Installing {} via {} (using InstallerService, force={})", - tool, method, force - ); - - // 获取工具定义 - let tool_obj = - Tool::by_id(&tool).ok_or_else(|| "❌ 未知的工具\n\n请联系开发者报告此问题".to_string())?; - - // 转换安装方法 - let install_method = match method.as_str() { - "npm" => InstallMethod::Npm, - "brew" => InstallMethod::Brew, - "official" => InstallMethod::Official, - _ => return Err(format!("❌ 未知的安装方法: {}", method)), - }; - - // 使用 InstallerService 安装 - let installer = InstallerService::new(); - - match installer.install(&tool_obj, &install_method, force).await { - Ok(_) => { - // 安装成功,构造成功消息 - let message = match method.as_str() { - "npm" => format!("✅ {} 安装成功!(通过 npm)", tool_obj.name), - "brew" => format!("✅ {} 安装成功!(通过 Homebrew)", tool_obj.name), - "official" => format!("✅ {} 安装成功!", tool_obj.name), - _ => format!("✅ {} 安装成功!", tool_obj.name), - }; - - Ok(InstallResult { - success: true, - message, - output: String::new(), - }) - } - Err(e) => { - // 安装失败,返回错误信息 - Err(e.to_string()) - } - } -} - -// 只检查更新,不执行 -#[tauri::command] -async fn check_update(tool: String) -> Result { - // 应用代理配置(如果已配置) - apply_proxy_if_configured().await; - - #[cfg(debug_assertions)] - println!("Checking updates for {} (using VersionService)", tool); - - let tool_obj = Tool::by_id(&tool).ok_or_else(|| format!("未知工具: {}", tool))?; - - let version_service = VersionService::new(); - - match version_service.check_version(&tool_obj).await { - Ok(version_info) => Ok(UpdateResult { - success: true, - message: "检查完成".to_string(), - has_update: version_info.has_update, - current_version: version_info.installed_version, - latest_version: version_info.latest_version, - mirror_version: version_info.mirror_version, - mirror_is_stale: Some(version_info.mirror_is_stale), - tool_id: Some(tool.clone()), - }), - Err(e) => { - // 降级:如果检查失败,返回无法检查但不报错 - Ok(UpdateResult { - success: true, - message: format!("无法检查更新: {}", e), - has_update: false, - current_version: None, - latest_version: None, - mirror_version: None, - mirror_is_stale: None, - tool_id: Some(tool.clone()), - }) - } - } -} - -// 批量检查所有工具更新(优化:单次网络请求) -#[tauri::command] -async fn check_all_updates() -> Result, String> { - // 应用代理配置(如果已配置) - apply_proxy_if_configured().await; - - #[cfg(debug_assertions)] - println!("Checking updates for all tools (batch mode)"); - - let version_service = VersionService::new(); - let version_infos = version_service.check_all_tools().await; - - let results = version_infos - .into_iter() - .map(|info| UpdateResult { - success: true, - message: "检查完成".to_string(), - has_update: info.has_update, - current_version: info.installed_version, - latest_version: info.latest_version, - mirror_version: info.mirror_version, - mirror_is_stale: Some(info.mirror_is_stale), - tool_id: Some(info.tool_id), - }) - .collect(); - - Ok(results) -} - -#[tauri::command] -async fn update_tool(tool: String, force: Option) -> Result { - // 应用代理配置(如果已配置) - apply_proxy_if_configured().await; - - let force = force.unwrap_or(false); - #[cfg(debug_assertions)] - println!( - "Updating {} (using InstallerService, force={})", - tool, force - ); - - // 获取工具定义 - let tool_obj = - Tool::by_id(&tool).ok_or_else(|| "❌ 未知的工具\n\n请联系开发者报告此问题".to_string())?; - - // 使用 InstallerService 更新(内部有120秒超时) - let installer = InstallerService::new(); - - // 执行更新,添加超时控制 - use tokio::time::{timeout, Duration}; - - let update_result = timeout(Duration::from_secs(120), installer.update(&tool_obj, force)).await; - - match update_result { - Ok(Ok(_)) => { - // 更新成功,获取新版本 - let new_version = installer.get_installed_version(&tool_obj).await; - - Ok(UpdateResult { - success: true, - message: "✅ 更新成功!".to_string(), - has_update: false, - current_version: new_version.clone(), - latest_version: new_version, - mirror_version: None, - mirror_is_stale: None, - tool_id: Some(tool.clone()), - }) - } - Ok(Err(e)) => { - // 更新失败,检查特殊错误情况 - let error_str = e.to_string(); - - // 检查是否是 Homebrew 版本滞后 - if error_str.contains("Not upgrading") && error_str.contains("already installed") { - return Err( - "⚠️ Homebrew版本滞后\n\nHomebrew cask的版本更新不及时,目前是旧版本。\n\n✅ 解决方案:\n\n方案1 - 使用npm安装最新版本(自动使用国内镜像):\n1. 卸载Homebrew版本:brew uninstall --cask codex\n2. 安装npm版本:npm install -g @openai/codex --registry https://registry.npmmirror.com\n\n方案2 - 等待Homebrew cask更新\n(可能需要几天到几周时间)\n\n推荐使用方案1,npm版本更新更及时。".to_string() - ); - } - - // 检查npm是否显示已经是最新版本 - if error_str.contains("up to date") { - return Err( - "ℹ️ 已是最新版本\n\n当前安装的版本已经是最新版本,无需更新。".to_string(), - ); - } - - // 检查是否是 npm 缓存权限错误 - if error_str.contains("EACCES") && error_str.contains(".npm") { - return Err( - "⚠️ npm 权限问题\n\n这是因为之前使用 sudo npm 安装导致的。\n\n✅ 解决方案(任选其一):\n\n方案1 - 修复 npm 权限(推荐):\n在终端运行:\nsudo chown -R $(id -u):$(id -g) \"$HOME/.npm\"\n\n方案2 - 配置 npm 使用用户目录:\nnpm config set prefix ~/.npm-global\nexport PATH=~/.npm-global/bin:$PATH\n\n方案3 - macOS 用户切换到 Homebrew(无需 sudo):\nbrew uninstall --cask codex\nbrew install --cask codex\n\n然后重试更新。".to_string() - ); - } - - // 其他错误 - Err(error_str) - } - Err(_) => { - // 超时 - Err("⏱️ 更新超时(120秒)\n\n可能的原因:\n• 网络连接不稳定\n• 服务器响应慢\n\n建议:\n1. 检查网络连接\n2. 重试更新\n3. 或尝试手动更新(详见文档)".to_string()) - } - } -} - -#[tauri::command] -async fn configure_api( - tool: String, - _provider: String, - api_key: String, - base_url: Option, - profile_name: Option, -) -> Result<(), String> { - #[cfg(debug_assertions)] - println!("Configuring {} (using ConfigService)", tool); - - // 获取工具定义 - let tool_obj = Tool::by_id(&tool).ok_or_else(|| format!("❌ 未知的工具: {}", tool))?; - - // 获取 base_url,根据工具类型使用不同的默认值 - let base_url_str = base_url.unwrap_or_else(|| match tool.as_str() { - "codex" => "https://jp.duckcoding.com/v1".to_string(), - _ => "https://jp.duckcoding.com".to_string(), - }); - - // 使用 ConfigService 应用配置 - ConfigService::apply_config(&tool_obj, &api_key, &base_url_str, profile_name.as_deref()) - .map_err(|e| e.to_string())?; - - Ok(()) -} - -#[tauri::command] -async fn list_profiles(tool: String) -> Result, String> { - #[cfg(debug_assertions)] - println!("Listing profiles for {} (using ConfigService)", tool); - - // 获取工具定义 - let tool_obj = Tool::by_id(&tool).ok_or_else(|| format!("❌ 未知的工具: {}", tool))?; - - // 使用 ConfigService 列出配置 - ConfigService::list_profiles(&tool_obj).map_err(|e| e.to_string()) -} - -#[tauri::command] -async fn switch_profile( - tool: String, - profile: String, - state: tauri::State<'_, TransparentProxyState>, -) -> Result<(), String> { - #[cfg(debug_assertions)] - println!( - "Switching profile for {} to {} (using ConfigService)", - tool, profile - ); - - // 获取工具定义 - let tool_obj = Tool::by_id(&tool).ok_or_else(|| format!("❌ 未知的工具: {}", tool))?; - - // 使用 ConfigService 激活配置 - ConfigService::activate_profile(&tool_obj, &profile).map_err(|e| e.to_string())?; - - // 如果是 ClaudeCode 且透明代理已启用,需要更新真实配置 - if tool == "claude-code" { - // 读取全局配置 - if let Ok(Some(mut global_config)) = get_global_config().await { - if global_config.transparent_proxy_enabled { - // 读取切换后的真实配置 - let config_path = tool_obj.config_dir.join(&tool_obj.config_file); - if config_path.exists() { - if let Ok(content) = fs::read_to_string(&config_path) { - if let Ok(settings) = serde_json::from_str::(&content) { - if let Some(env) = settings.get("env").and_then(|v| v.as_object()) { - let new_api_key = env - .get("ANTHROPIC_AUTH_TOKEN") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let new_base_url = env - .get("ANTHROPIC_BASE_URL") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - // 检查透明代理功能是否启用 - let transparent_proxy_enabled = - global_config.transparent_proxy_enabled; - - if !new_api_key.is_empty() && !new_base_url.is_empty() { - // 总是保存新的真实配置到全局配置(不管代理是否在运行) - TransparentProxyConfigService::update_real_config( - &tool_obj, - &mut global_config, - new_api_key, - new_base_url, - ) - .map_err(|e| format!("更新真实配置失败: {}", e))?; - - // 保存全局配置 - save_global_config(global_config.clone()) - .await - .map_err(|e| format!("保存全局配置失败: {}", e))?; - - // 如果透明代理功能启用且代理服务正在运行,更新代理配置 - if transparent_proxy_enabled { - let service = state.service.lock().await; - if service.is_running().await { - let local_api_key = global_config - .transparent_proxy_api_key - .clone() - .unwrap_or_default(); - - let proxy_config = ProxyConfig { - target_api_key: new_api_key.to_string(), - target_base_url: new_base_url.to_string(), - local_api_key, - }; - - service - .update_config(proxy_config) - .await - .map_err(|e| format!("更新代理配置失败: {}", e))?; - - println!("✅ 透明代理配置已自动更新"); - drop(service); // 释放锁 - } // 闭合 if service.is_running() - } // 闭合 if transparent_proxy_enabled - - // 只有在透明代理功能启用时才恢复 ClaudeCode 配置指向本地代理 - if transparent_proxy_enabled { - let local_proxy_port = global_config.transparent_proxy_port; - let local_proxy_key = global_config - .transparent_proxy_api_key - .unwrap_or_default(); - - let mut settings_mut = settings.clone(); - if let Some(env_mut) = settings_mut - .get_mut("env") - .and_then(|v| v.as_object_mut()) - { - env_mut.insert( - "ANTHROPIC_AUTH_TOKEN".to_string(), - Value::String(local_proxy_key), - ); - env_mut.insert( - "ANTHROPIC_BASE_URL".to_string(), - Value::String(format!( - "http://127.0.0.1:{}", - local_proxy_port - )), - ); - - let json = serde_json::to_string_pretty(&settings_mut) - .map_err(|e| format!("序列化配置失败: {}", e))?; - fs::write(&config_path, json) - .map_err(|e| format!("写入配置失败: {}", e))?; - - println!("✅ ClaudeCode 配置已恢复指向本地代理"); - } - } - } - } - } - } - } - } - } - } - - Ok(()) -} - -#[tauri::command] -async fn delete_profile(tool: String, profile: String) -> Result<(), String> { - #[cfg(debug_assertions)] - println!("Deleting profile: tool={}, profile={}", tool, profile); - - // 获取工具定义 - let tool_obj = Tool::by_id(&tool).ok_or_else(|| format!("❌ 未知的工具: {}", tool))?; - - // 使用 ConfigService 删除配置 - ConfigService::delete_profile(&tool_obj, &profile).map_err(|e| e.to_string())?; - - #[cfg(debug_assertions)] - println!("Successfully deleted profile: {}", profile); - - Ok(()) -} - -// 数据结构定义 -#[derive(serde::Serialize, serde::Deserialize, Clone)] -struct ToolStatus { - id: String, - name: String, - installed: bool, - version: Option, -} - -#[derive(serde::Serialize, serde::Deserialize)] -struct NodeEnvironment { - node_available: bool, - node_version: Option, - npm_available: bool, - npm_version: Option, -} - -#[derive(serde::Serialize, serde::Deserialize)] -struct InstallResult { - success: bool, - message: String, - output: String, -} - -#[derive(serde::Serialize, serde::Deserialize)] -struct UpdateResult { - success: bool, - message: String, - has_update: bool, - current_version: Option, - latest_version: Option, - mirror_version: Option, // 镜像实际可安装的版本 - mirror_is_stale: Option, // 镜像是否滞后 - tool_id: Option, // 新增:工具ID,用于批量检查时识别工具 -} - -#[derive(serde::Serialize, serde::Deserialize)] -struct ActiveConfig { - api_key: String, - base_url: String, - profile_name: Option, // 当前配置的名称 -} - -// 全局配置辅助函数 -fn get_global_config_path() -> Result { - let home_dir = dirs::home_dir().ok_or("Failed to get home directory")?; - let config_dir = home_dir.join(".duckcoding"); - if !config_dir.exists() { - fs::create_dir_all(&config_dir) - .map_err(|e| format!("Failed to create config directory: {}", e))?; - } - Ok(config_dir.join("config.json")) -} - -// Tauri命令:保存全局配置 -#[tauri::command] -async fn save_global_config(config: GlobalConfig) -> Result<(), String> { - let config_path = get_global_config_path()?; - - let json = serde_json::to_string_pretty(&config) - .map_err(|e| format!("Failed to serialize config: {}", e))?; - - fs::write(&config_path, json).map_err(|e| format!("Failed to write config: {}", e))?; - - println!("Config saved successfully"); - - // 设置文件权限为仅所有者可读写(Unix系统) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let metadata = fs::metadata(&config_path) - .map_err(|e| format!("Failed to get file metadata: {}", e))?; - let mut perms = metadata.permissions(); - perms.set_mode(0o600); // -rw------- - fs::set_permissions(&config_path, perms) - .map_err(|e| format!("Failed to set file permissions: {}", e))?; - } - - // 立即应用代理配置到环境变量 - duckcoding::ProxyService::apply_proxy_from_config(&config); - - Ok(()) -} - -// Tauri命令:读取全局配置 -#[tauri::command] -async fn get_global_config() -> Result, String> { - let config_path = get_global_config_path()?; - - if !config_path.exists() { - return Ok(None); - } - - let content = - fs::read_to_string(&config_path).map_err(|e| format!("Failed to read config: {}", e))?; - - let config: GlobalConfig = - serde_json::from_str(&content).map_err(|e| format!("Failed to parse config: {}", e))?; - - Ok(Some(config)) -} - -// 生成API Key的主函数 -#[tauri::command] -async fn generate_api_key_for_tool(tool: String) -> Result { - // 应用代理配置(如果已配置) - apply_proxy_if_configured().await; - - // 读取全局配置 - let global_config = get_global_config() - .await? - .ok_or("请先配置用户ID和系统访问令牌")?; - - // 根据工具名称获取配置 - let (name, group) = match tool.as_str() { - "claude-code" => ("Claude Code一键创建", "Claude Code专用"), - "codex" => ("CodeX一键创建", "CodeX专用"), - "gemini-cli" => ("Gemini CLI一键创建", "Gemini CLI专用"), - _ => return Err(format!("Unknown tool: {}", tool)), - }; - - // 创建token - let client = build_reqwest_client().map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; - let create_url = "https://duckcoding.com/api/token"; - - let create_body = serde_json::json!({ - "remain_quota": 500000, - "expired_time": -1, - "unlimited_quota": true, - "model_limits_enabled": false, - "model_limits": "", - "name": name, - "group": group, - "allow_ips": "" - }); - - let create_response = client - .post(create_url) - .header( - "Authorization", - format!("Bearer {}", global_config.system_token), - ) - .header("New-Api-User", &global_config.user_id) - .header("Content-Type", "application/json") - .json(&create_body) - .send() - .await - .map_err(|e| format!("创建token失败: {}", e))?; - - if !create_response.status().is_success() { - let status = create_response.status(); - let error_text = create_response.text().await.unwrap_or_default(); - return Ok(GenerateApiKeyResult { - success: false, - message: format!("创建token失败 ({}): {}", status, error_text), - api_key: None, - }); - } - - // 等待一小段时间让服务器处理 - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - // 搜索刚创建的token - let search_url = format!( - "https://duckcoding.com/api/token/search?keyword={}", - urlencoding::encode(name) - ); - - let search_response = client - .get(&search_url) - .header( - "Authorization", - format!("Bearer {}", global_config.system_token), - ) - .header("New-Api-User", &global_config.user_id) - .header("Content-Type", "application/json") - .send() - .await - .map_err(|e| format!("搜索token失败: {}", e))?; - - if !search_response.status().is_success() { - return Ok(GenerateApiKeyResult { - success: false, - message: "创建成功但获取API Key失败,请稍后在DuckCoding控制台查看".to_string(), - api_key: None, - }); - } - - let api_response: ApiResponse = search_response - .json() - .await - .map_err(|e| format!("解析响应失败: {}", e))?; - - if !api_response.success { - return Ok(GenerateApiKeyResult { - success: false, - message: format!("API返回错误: {}", api_response.message), - api_key: None, - }); - } - - // 获取id最大的token(最新创建的) - if let Some(mut data) = api_response.data { - if !data.is_empty() { - // 按id降序排序,取第一个(id最大的) - data.sort_by(|a, b| b.id.cmp(&a.id)); - let token = &data[0]; - let api_key = format!("sk-{}", token.key); - return Ok(GenerateApiKeyResult { - success: true, - message: "API Key生成成功".to_string(), - api_key: Some(api_key), - }); - } - } - - Ok(GenerateApiKeyResult { - success: false, - message: "未找到生成的token".to_string(), - api_key: None, - }) -} - -// 获取用户用量统计(近30天) -#[tauri::command] -async fn get_usage_stats() -> Result { - // 应用代理配置(如果已配置) - apply_proxy_if_configured().await; - - // 读取全局配置 - let global_config = get_global_config() - .await? - .ok_or("请先配置用户ID和系统访问令牌")?; - - // 计算时间戳(北京时间) - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - - // 今天的24:00:00(加上8小时时区偏移,然后取第二天的0点) - let beijing_offset = 8 * 3600; - let today_end = (now + beijing_offset) / 86400 * 86400 + 86400 - beijing_offset; - - // 30天前的00:00:00 - let start_timestamp = today_end - 30 * 86400; - let end_timestamp = today_end; - - // 调用API - let client = build_reqwest_client().map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; - let url = format!( - "https://duckcoding.com/api/data/self?start_timestamp={}&end_timestamp={}", - start_timestamp, end_timestamp - ); - - let response = client - .get(&url) - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/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) - .send() - .await - .map_err(|e| format!("获取用量统计失败: {}", e))?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - return Ok(UsageStatsResult { - success: false, - message: format!("获取用量统计失败 ({}): {}", status, error_text), - data: vec![], - }); - } - - // 检查 Content-Type 是否为 JSON - let content_type = response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) // 克隆字符串,避免借用冲突 - .unwrap_or_default(); - - if !content_type.contains("application/json") { - return Ok(UsageStatsResult { - success: false, - message: format!( - "服务器返回了非JSON格式的响应 (Content-Type: {})", - content_type - ), - data: vec![], - }); - } - - let api_response: UsageApiResponse = response - .json() - .await - .map_err(|e| format!("解析响应失败: {}", e))?; - - if !api_response.success { - return Ok(UsageStatsResult { - success: false, - message: format!("API返回错误: {}", api_response.message), - data: vec![], - }); - } - - Ok(UsageStatsResult { - success: true, - message: "获取成功".to_string(), - data: api_response.data.unwrap_or_default(), - }) -} - -// 获取用户额度信息 -#[tauri::command] -async fn get_user_quota() -> Result { - // 应用代理配置(如果已配置) - apply_proxy_if_configured().await; - - // 读取全局配置 - let global_config = get_global_config() - .await? - .ok_or("请先配置用户ID和系统访问令牌")?; - - // 调用API - let client = build_reqwest_client().map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; - let url = "https://duckcoding.com/api/user/self"; - - let response = client - .get(url) - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/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) - .send() - .await - .map_err(|e| format!("获取用户信息失败: {}", e))?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - println!("❌ [get_user_quota] HTTP Error: {}", error_text); - return Err(format!("获取用户信息失败 ({}): {}", status, error_text)); - } - - // 检查 Content-Type 是否为 JSON - let content_type = response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) // 克隆字符串,避免借用冲突 - .unwrap_or_default(); - - if !content_type.contains("application/json") { - return Err(format!( - "服务器返回了非JSON格式的响应 (Content-Type: {})", - content_type - )); - } - - let api_response: UserApiResponse = response - .json() - .await - .map_err(|e| format!("解析响应失败: {}", e))?; - - if !api_response.success { - return Err(format!("API返回错误: {}", api_response.message)); - } - - let user_info = api_response.data.ok_or("未获取到用户信息")?; - - // 修正:API返回的quota是剩余额度,不是总额度 - // 正确计算:总额度 = 剩余额度 + 已用额度 - let remaining_quota = user_info.quota as f64 / 500000.0; - let used_quota = user_info.used_quota as f64 / 500000.0; - let total_quota = remaining_quota + used_quota; - - #[cfg(debug_assertions)] - { - println!( - "Raw remaining: {}, converted: {}", - user_info.quota, remaining_quota - ); - println!( - "Raw used: {}, converted: {}", - user_info.used_quota, used_quota - ); - println!("Total quota: {}", total_quota); - } - - Ok(UserQuotaResult { - success: true, - message: "获取成功".to_string(), - total_quota, - used_quota, - remaining_quota, - request_count: user_info.request_count, - }) -} - -#[tauri::command] -fn handle_close_action(window: WebviewWindow, action: String) -> Result<(), String> { - match action.as_str() { - "minimize" => { - hide_window_to_tray(&window); - Ok(()) - } - "quit" => { - window.app_handle().exit(0); - Ok(()) - } - other => Err(format!("未知的关闭操作: {}", other)), - } -} - -#[tauri::command] -fn get_claude_settings() -> Result { - ConfigService::read_claude_settings().map_err(|e| e.to_string()) -} - -#[tauri::command] -fn save_claude_settings(settings: Value) -> Result<(), String> { - ConfigService::save_claude_settings(&settings).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn get_claude_schema() -> Result { - ConfigService::get_claude_schema().map_err(|e| e.to_string()) -} - -#[tauri::command] -fn get_codex_settings() -> Result { - ConfigService::read_codex_settings().map_err(|e| e.to_string()) -} - -#[tauri::command] -fn save_codex_settings(settings: Value, auth_token: Option) -> Result<(), String> { - ConfigService::save_codex_settings(&settings, auth_token).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn get_codex_schema() -> Result { - ConfigService::get_codex_schema().map_err(|e| e.to_string()) -} - -#[tauri::command] -fn get_gemini_settings() -> Result { - ConfigService::read_gemini_settings().map_err(|e| e.to_string()) -} - -#[tauri::command] -fn save_gemini_settings(settings: Value, env: GeminiEnvPayload) -> Result<(), String> { - ConfigService::save_gemini_settings(&settings, &env).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn get_gemini_schema() -> Result { - ConfigService::get_gemini_schema().map_err(|e| e.to_string()) -} - -// 辅助函数:检测当前配置匹配哪个profile -fn detect_profile_name( - tool: &str, - active_api_key: &str, - active_base_url: &str, - home_dir: &std::path::Path, -) -> Option { - let config_dir = match tool { - "claude-code" => home_dir.join(".claude"), - "codex" => home_dir.join(".codex"), - "gemini-cli" => home_dir.join(".gemini"), - _ => return None, - }; - - if !config_dir.exists() { - return None; - } - - // 遍历配置目录,查找匹配的备份文件 - if let Ok(entries) = fs::read_dir(&config_dir) { - for entry in entries.flatten() { - let file_name = entry.file_name(); - let file_name_str = file_name.to_string_lossy(); - - // 根据工具类型匹配不同的备份文件格式 - let profile_name = match tool { - "claude-code" => { - // 匹配 settings.{profile}.json - if file_name_str.starts_with("settings.") - && file_name_str.ends_with(".json") - && file_name_str != "settings.json" - { - file_name_str - .strip_prefix("settings.") - .and_then(|s| s.strip_suffix(".json")) - } else { - None - } - } - "codex" => { - // 匹配 config.{profile}.toml - if file_name_str.starts_with("config.") - && file_name_str.ends_with(".toml") - && file_name_str != "config.toml" - { - file_name_str - .strip_prefix("config.") - .and_then(|s| s.strip_suffix(".toml")) - } else { - None - } - } - "gemini-cli" => { - // 匹配 .env.{profile} - if file_name_str.starts_with(".env.") && file_name_str != ".env" { - file_name_str.strip_prefix(".env.") - } else { - None - } - } - _ => None, - }; - - if let Some(profile) = profile_name { - // 读取备份文件并比较内容 - let is_match = match tool { - "claude-code" => { - if let Ok(content) = fs::read_to_string(entry.path()) { - if let Ok(config) = serde_json::from_str::(&content) { - let env_api_key = config - .get("env") - .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) - .and_then(|v| v.as_str()); - let env_base_url = config - .get("env") - .and_then(|env| env.get("ANTHROPIC_BASE_URL")) - .and_then(|v| v.as_str()); - - let flat_api_key = - config.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()); - let flat_base_url = - config.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()); - - let backup_api_key = env_api_key.or(flat_api_key).unwrap_or(""); - let backup_base_url = env_base_url.or(flat_base_url).unwrap_or(""); - - backup_api_key == active_api_key - && backup_base_url == active_base_url - } else { - false - } - } else { - false - } - } - "codex" => { - // 需要同时检查 config.toml 和 auth.json - let auth_backup = config_dir.join(format!("auth.{}.json", profile)); - - let mut api_key_matches = false; - if let Ok(auth_content) = fs::read_to_string(&auth_backup) { - if let Ok(auth) = serde_json::from_str::(&auth_content) { - let backup_api_key = auth - .get("OPENAI_API_KEY") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - api_key_matches = backup_api_key == active_api_key; - } - } - - if !api_key_matches { - false - } else { - // API Key 匹配,继续检查 base_url - if let Ok(config_content) = fs::read_to_string(entry.path()) { - if let Ok(toml::Value::Table(table)) = - toml::from_str::(&config_content) - { - if let Some(toml::Value::Table(providers)) = - table.get("model_providers") - { - let mut url_matches = false; - for (_, provider) in providers { - if let toml::Value::Table(p) = provider { - if let Some(toml::Value::String(url)) = - p.get("base_url") - { - if url == active_base_url { - url_matches = true; - break; - } - } - } - } - url_matches - } else { - false - } - } else { - false - } - } else { - false - } - } - } - "gemini-cli" => { - if let Ok(content) = fs::read_to_string(entry.path()) { - let mut backup_api_key = ""; - let mut backup_base_url = ""; - - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - - if let Some((key, value)) = line.split_once('=') { - match key.trim() { - "GEMINI_API_KEY" => backup_api_key = value.trim(), - "GOOGLE_GEMINI_BASE_URL" => backup_base_url = value.trim(), - _ => {} - } - } - } - - backup_api_key == active_api_key && backup_base_url == active_base_url - } else { - false - } - } - _ => false, - }; - - if is_match { - return Some(profile.to_string()); - } - } - } - } - - None -} - -#[tauri::command] -async fn get_active_config(tool: String) -> Result { - let home_dir = dirs::home_dir().ok_or("❌ 无法获取用户主目录")?; - - match tool.as_str() { - "claude-code" => { - let config_path = home_dir.join(".claude").join("settings.json"); - if !config_path.exists() { - return Ok(ActiveConfig { - api_key: "未配置".to_string(), - base_url: "未配置".to_string(), - profile_name: None, - }); - } - - let content = - fs::read_to_string(&config_path).map_err(|e| format!("❌ 读取配置失败: {}", e))?; - let config: Value = - serde_json::from_str(&content).map_err(|e| format!("❌ 解析配置失败: {}", e))?; - - let raw_api_key = config - .get("env") - .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let api_key = if raw_api_key.is_empty() { - "未配置".to_string() - } else { - mask_api_key(raw_api_key) - }; - - let base_url = config - .get("env") - .and_then(|env| env.get("ANTHROPIC_BASE_URL")) - .and_then(|v| v.as_str()) - .unwrap_or("未配置"); - - // 检测配置名称 - let profile_name = if !raw_api_key.is_empty() && base_url != "未配置" { - detect_profile_name("claude-code", raw_api_key, base_url, &home_dir) - } else { - None - }; - - Ok(ActiveConfig { - api_key, - base_url: base_url.to_string(), - profile_name, - }) - } - "codex" => { - let auth_path = home_dir.join(".codex").join("auth.json"); - let config_path = home_dir.join(".codex").join("config.toml"); - - let mut raw_api_key = String::new(); - let mut api_key = "未配置".to_string(); - let mut base_url = "未配置".to_string(); - - // 读取 auth.json - if auth_path.exists() { - let content = fs::read_to_string(&auth_path) - .map_err(|e| format!("❌ 读取认证文件失败: {}", e))?; - let auth: Value = serde_json::from_str(&content) - .map_err(|e| format!("❌ 解析认证文件失败: {}", e))?; - - if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) { - raw_api_key = key.to_string(); - api_key = mask_api_key(key); - } - } - - // 读取 config.toml - if config_path.exists() { - let content = fs::read_to_string(&config_path) - .map_err(|e| format!("❌ 读取配置文件失败: {}", e))?; - let config: toml::Value = - toml::from_str(&content).map_err(|e| format!("❌ 解析TOML失败: {}", e))?; - - if let toml::Value::Table(table) = config { - let selected_provider = table - .get("model_provider") - .and_then(|value| value.as_str()) - .map(|s| s.to_string()); - - if let Some(toml::Value::Table(providers)) = table.get("model_providers") { - if let Some(provider_name) = selected_provider.as_deref() { - if let Some(toml::Value::Table(provider_table)) = - providers.get(provider_name) - { - if let Some(toml::Value::String(url)) = - provider_table.get("base_url") - { - base_url = url.clone(); - } - } - } - - if base_url == "未配置" { - for (_, provider) in providers { - if let toml::Value::Table(p) = provider { - if let Some(toml::Value::String(url)) = p.get("base_url") { - base_url = url.clone(); - break; - } - } - } - } - } - } - } - - // 检测配置名称 - let profile_name = if !raw_api_key.is_empty() && base_url != "未配置" { - detect_profile_name("codex", &raw_api_key, &base_url, &home_dir) - } else { - None - }; - - Ok(ActiveConfig { - api_key, - base_url, - profile_name, - }) - } - "gemini-cli" => { - let env_path = home_dir.join(".gemini").join(".env"); - if !env_path.exists() { - return Ok(ActiveConfig { - api_key: "未配置".to_string(), - base_url: "未配置".to_string(), - profile_name: None, - }); - } - - let content = fs::read_to_string(&env_path) - .map_err(|e| format!("❌ 读取环境变量配置失败: {}", e))?; - - let mut raw_api_key = String::new(); - let mut api_key = "未配置".to_string(); - let mut base_url = "未配置".to_string(); - - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - - if let Some((key, value)) = line.split_once('=') { - match key.trim() { - "GEMINI_API_KEY" => { - raw_api_key = value.trim().to_string(); - api_key = mask_api_key(value.trim()); - } - "GOOGLE_GEMINI_BASE_URL" => base_url = value.trim().to_string(), - _ => {} - } - } - } - - // 检测配置名称 - let profile_name = if !raw_api_key.is_empty() && base_url != "未配置" { - detect_profile_name("gemini-cli", &raw_api_key, &base_url, &home_dir) - } else { - None - }; - - Ok(ActiveConfig { - api_key, - base_url, - profile_name, - }) - } - _ => Err(format!("❌ 未知的工具: {}", tool)), - } -} - -fn mask_api_key(key: &str) -> String { - if key.len() <= 8 { - return "****".to_string(); - } - let prefix = &key[..4]; - let suffix = &key[key.len() - 4..]; - format!("{}...{}", prefix, suffix) -} - -fn create_tray_menu(app: &AppHandle) -> tauri::Result> { - let show_item = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?; - let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; - - let menu = Menu::with_items( - app, - &[&show_item, &PredefinedMenuItem::separator(app)?, &quit_item], - )?; - - Ok(menu) -} - -fn focus_main_window(app: &AppHandle) { - if let Some(window) = app.get_webview_window("main") { - println!("Focusing existing main window"); - restore_window_state(&window); - } else { - println!("Main window not found when trying to focus"); - } -} - -fn restore_window_state(window: &WebviewWindow) { - println!( - "Restoring window state, is_visible={:?}, is_minimized={:?}", - window.is_visible(), - window.is_minimized() - ); - - #[cfg(target_os = "macos")] - #[allow(deprecated)] - { - use cocoa::appkit::NSApplication; - use cocoa::base::nil; - use cocoa::foundation::NSAutoreleasePool; - - unsafe { - let _pool = NSAutoreleasePool::new(nil); - let app_macos = NSApplication::sharedApplication(nil); - app_macos.setActivationPolicy_( - cocoa::appkit::NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, - ); - } - println!("macOS Dock icon restored"); - } - - if let Err(e) = window.show() { - println!("Error showing window: {:?}", e); - } - if let Err(e) = window.unminimize() { - println!("Error unminimizing window: {:?}", e); - } - if let Err(e) = window.set_focus() { - println!("Error setting focus: {:?}", e); - } - - #[cfg(target_os = "macos")] - #[allow(deprecated)] - { - use cocoa::appkit::NSApplication; - use cocoa::base::nil; - use objc::runtime::YES; - - unsafe { - let ns_app = NSApplication::sharedApplication(nil); - ns_app.activateIgnoringOtherApps_(YES); - } - println!("macOS app activated"); - } -} - -fn hide_window_to_tray(window: &WebviewWindow) { - println!("Hiding window to system tray"); - if let Err(e) = window.hide() { - println!("Failed to hide window: {:?}", e); - } - - #[cfg(target_os = "macos")] - #[allow(deprecated)] - { - use cocoa::appkit::NSApplication; - use cocoa::base::nil; - use cocoa::foundation::NSAutoreleasePool; - - unsafe { - let _pool = NSAutoreleasePool::new(nil); - let app_macos = NSApplication::sharedApplication(nil); - app_macos.setActivationPolicy_( - cocoa::appkit::NSApplicationActivationPolicy::NSApplicationActivationPolicyAccessory, - ); - } - println!("macOS Dock icon hidden"); - } -} - -// Helper: 统一使用库中的 HTTP 客户端构建逻辑(支持 SOCKS5 等代理) -fn build_reqwest_client() -> Result { - duckcoding::http_client::build_client() -} - -// 透明代理全局状态 -struct TransparentProxyState { - service: Arc>, -} - -// 透明代理相关的 Tauri Commands -#[derive(serde::Serialize)] -struct TransparentProxyStatus { - running: bool, - port: u16, -} - -#[tauri::command] -async fn start_transparent_proxy( - state: tauri::State<'_, TransparentProxyState>, -) -> Result { - // 读取全局配置 - let mut config = get_global_config() - .await - .map_err(|e| format!("读取配置失败: {}", e))? - .ok_or_else(|| "全局配置不存在,请先配置用户信息".to_string())?; - - if !config.transparent_proxy_enabled { - return Err("透明代理未启用,请先在设置中启用".to_string()); - } - - let local_api_key = config - .transparent_proxy_api_key - .clone() - .ok_or_else(|| "透明代理保护密钥未设置".to_string())?; - - let proxy_port = config.transparent_proxy_port; - - let tool = Tool::claude_code(); - - // 每次启动都检查并确保配置正确设置 - // 如果还没有备份过真实配置,先备份 - if config.transparent_proxy_real_api_key.is_none() { - // 启用透明代理(保存真实配置并修改 ClaudeCode 配置) - TransparentProxyConfigService::enable_transparent_proxy( - &tool, - &mut config, - proxy_port, - &local_api_key, - ) - .map_err(|e| format!("启用透明代理失败: {}", e))?; - - // 保存更新后的全局配置 - save_global_config(config.clone()) - .await - .map_err(|e| format!("保存配置失败: {}", e))?; - } else { - // 已经备份过配置,只需确保当前配置指向本地代理 - TransparentProxyConfigService::update_config_to_proxy(&tool, proxy_port, &local_api_key) - .map_err(|e| format!("更新代理配置失败: {}", e))?; - } - - // 从全局配置获取真实的 API 配置 - let (target_api_key, target_base_url) = TransparentProxyConfigService::get_real_config(&config) - .map_err(|e| format!("获取真实配置失败: {}", e))?; - - println!( - "🔑 真实 API Key: {}...", - &target_api_key[..4.min(target_api_key.len())] - ); - println!("🌐 真实 Base URL: {}", target_base_url); - - // 创建代理配置 - let proxy_config = ProxyConfig { - target_api_key, - target_base_url, - local_api_key, - }; - - // 启动代理服务 - let service = state.service.lock().await; - let allow_public = config.transparent_proxy_allow_public; - service - .start(proxy_config, allow_public) - .await - .map_err(|e| format!("启动透明代理服务失败: {}", e))?; - - Ok(format!( - "✅ 透明代理已启动\n监听端口: {}\nClaudeCode 请求将自动转发", - proxy_port - )) -} - -#[tauri::command] -async fn stop_transparent_proxy( - state: tauri::State<'_, TransparentProxyState>, -) -> Result { - // 读取全局配置 - let config = get_global_config() - .await - .map_err(|e| format!("读取配置失败: {}", e))? - .ok_or_else(|| "全局配置不存在".to_string())?; - - // 停止代理服务 - let service = state.service.lock().await; - service - .stop() - .await - .map_err(|e| format!("停止透明代理服务失败: {}", e))?; - - // 恢复 ClaudeCode 配置 - if config.transparent_proxy_real_api_key.is_some() { - let tool = Tool::claude_code(); - TransparentProxyConfigService::disable_transparent_proxy(&tool, &config) - .map_err(|e| format!("恢复配置失败: {}", e))?; - } - - Ok("✅ 透明代理已停止\nClaudeCode 配置已恢复".to_string()) -} - -#[tauri::command] -async fn get_transparent_proxy_status( - state: tauri::State<'_, TransparentProxyState>, -) -> Result { - let config = get_global_config().await.ok().flatten(); - let port = config - .as_ref() - .map(|c| c.transparent_proxy_port) - .unwrap_or(8787); - - let service = state.service.lock().await; - let running = service.is_running().await; - - Ok(TransparentProxyStatus { running, port }) -} - -#[tauri::command] -async fn update_transparent_proxy_config( - state: tauri::State<'_, TransparentProxyState>, - new_api_key: String, - new_base_url: String, -) -> Result { - // 读取全局配置 - let mut config = get_global_config() - .await - .map_err(|e| format!("读取配置失败: {}", e))? - .ok_or_else(|| "全局配置不存在".to_string())?; - - if !config.transparent_proxy_enabled { - return Err("透明代理未启用".to_string()); - } - - let local_api_key = config - .transparent_proxy_api_key - .clone() - .ok_or_else(|| "透明代理保护密钥未设置".to_string())?; - - // 更新全局配置中的真实配置 - let tool = Tool::claude_code(); - TransparentProxyConfigService::update_real_config( - &tool, - &mut config, - &new_api_key, - &new_base_url, - ) - .map_err(|e| format!("更新配置失败: {}", e))?; - - // 保存更新后的全局配置 - save_global_config(config.clone()) - .await - .map_err(|e| format!("保存配置失败: {}", e))?; - - // 创建新的代理配置 - let proxy_config = ProxyConfig { - target_api_key: new_api_key.clone(), - target_base_url: new_base_url.clone(), - local_api_key, - }; - - // 更新代理服务的配置 - let service = state.service.lock().await; - service - .update_config(proxy_config) - .await - .map_err(|e| format!("更新代理配置失败: {}", e))?; - - println!("🔄 透明代理配置已更新:"); - println!( - " API Key: {}...", - &new_api_key[..4.min(new_api_key.len())] - ); - println!(" Base URL: {}", new_base_url); - - Ok("✅ 透明代理配置已更新,无需重启".to_string()) -} - -fn main() { - // 创建透明代理服务实例 - let transparent_proxy_port = 8787; // 默认端口,实际会从配置读取 - let transparent_proxy_service = TransparentProxyService::new(transparent_proxy_port); - let transparent_proxy_state = TransparentProxyState { - service: Arc::new(TokioMutex::new(transparent_proxy_service)), - }; - - let builder = tauri::Builder::default() - .manage(transparent_proxy_state) - .setup(|app| { - // 尝试在应用启动时加载全局配置并应用代理设置,确保子进程继承代理 env - if let Ok(config_path) = get_global_config_path() { - if config_path.exists() { - if let Ok(content) = std::fs::read_to_string(&config_path) { - if let Ok(cfg) = serde_json::from_str::(&content) { - // 应用代理到环境变量(进程级) - duckcoding::ProxyService::apply_proxy_from_config(&cfg); - println!("Applied proxy from config at startup"); - } - } - } - } - - // 设置工作目录到项目根目录(跨平台支持) - if let Ok(resource_dir) = app.path().resource_dir() { - println!("Resource dir: {:?}", resource_dir); - - if cfg!(debug_assertions) { - // 开发模式:resource_dir 是 src-tauri/target/debug - // 需要回到项目根目录(上三级) - let project_root = resource_dir - .parent() // target - .and_then(|p| p.parent()) // src-tauri - .and_then(|p| p.parent()) // 项目根目录 - .unwrap_or(&resource_dir); - - println!("Development mode, setting dir to: {:?}", project_root); - let _ = env::set_current_dir(project_root); - } else { - // 生产模式:跨平台支持 - let parent_dir = if cfg!(target_os = "macos") { - // macOS: .app/Contents/Resources/ - resource_dir - .parent() - .and_then(|p| p.parent()) - .unwrap_or(&resource_dir) - } else if cfg!(target_os = "windows") { - // Windows: 通常在应用程序目录 - resource_dir.parent().unwrap_or(&resource_dir) - } else { - // Linux: 通常在 /usr/share/appname 或类似位置 - resource_dir.parent().unwrap_or(&resource_dir) - }; - println!("Production mode, setting dir to: {:?}", parent_dir); - let _ = env::set_current_dir(parent_dir); - } - } - - println!("Working directory: {:?}", env::current_dir()); - - // 创建系统托盘菜单 - let tray_menu = create_tray_menu(app.handle())?; - let app_handle2 = app.handle().clone(); - - let _tray = TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) - .menu(&tray_menu) - .show_menu_on_left_click(false) - .on_menu_event(move |app, event| { - println!("Tray menu event: {:?}", event.id); - match event.id.as_ref() { - "show" => { - println!("Show window requested from tray menu"); - focus_main_window(app); - } - "quit" => { - println!("Quit requested from tray menu"); - app.exit(0); - } - _ => {} - } - }) - .on_tray_icon_event(move |_tray, event| { - println!("Tray icon event received: {:?}", event); - match event { - TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } => { - println!("Tray icon LEFT click detected"); - focus_main_window(&app_handle2); - } - _ => { - // 不打印太多日志 - } - } - }) - .build(app)?; - - // 处理窗口关闭事件 - 最小化到托盘而不是退出 - if let Some(window) = app.get_webview_window("main") { - let window_clone = window.clone(); - - window.on_window_event(move |event| { - if let tauri::WindowEvent::CloseRequested { api, .. } = event { - println!("Window close requested - prompting for action"); - // 阻止默认关闭行为 - api.prevent_close(); - if let Err(err) = window_clone.emit(CLOSE_CONFIRM_EVENT, ()) { - println!( - "Failed to emit close confirmation event, fallback to hiding: {:?}", - err - ); - hide_window_to_tray(&window_clone); - } - } - }); - } - - Ok(()) - }) - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| { - println!( - "Secondary instance detected, args: {:?}, cwd: {}", - argv, cwd - ); - - if let Err(err) = app.emit( - SINGLE_INSTANCE_EVENT, - SingleInstancePayload { - args: argv.clone(), - cwd: cwd.clone(), - }, - ) { - println!("Failed to emit single-instance event: {:?}", err); - } - - focus_main_window(app); - })) - .invoke_handler(tauri::generate_handler![ - check_installations, - check_node_environment, - install_tool, - check_update, - check_all_updates, - update_tool, - configure_api, - list_profiles, - switch_profile, - delete_profile, - get_active_config, - save_global_config, - get_global_config, - generate_api_key_for_tool, - get_usage_stats, - get_user_quota, - handle_close_action, - // expose current proxy for debugging/testing - get_current_proxy, - apply_proxy_now, - test_proxy_request, - get_claude_settings, - save_claude_settings, - get_claude_schema, - get_codex_settings, - save_codex_settings, - get_codex_schema, - get_gemini_settings, - save_gemini_settings, - get_gemini_schema, - // 透明代理相关命令 - start_transparent_proxy, - stop_transparent_proxy, - get_transparent_proxy_status, - update_transparent_proxy_config, - // 更新管理相关命令 - check_for_app_updates, - download_app_update, - install_app_update, - get_app_update_status, - rollback_app_update, - get_current_app_version, - restart_app_for_update, - get_platform_info, - get_recommended_package_format, - ]); - - // 使用自定义事件循环处理 macOS Reopen 事件 - builder - .build(tauri::generate_context!()) - .expect("error while building tauri application") - .run(|app_handle, event| { - #[cfg(not(target_os = "macos"))] - { - let _ = app_handle; - let _ = event; - } - #[cfg(target_os = "macos")] - #[allow(deprecated)] - { - use cocoa::appkit::NSApplication; - use cocoa::base::nil; - use cocoa::foundation::NSAutoreleasePool; - use objc::runtime::YES; - - if let tauri::RunEvent::Reopen { .. } = event { - println!("macOS Reopen event detected"); - - if let Some(window) = app_handle.get_webview_window("main") { - unsafe { - let _pool = NSAutoreleasePool::new(nil); - let app_macos = NSApplication::sharedApplication(nil); - app_macos.setActivationPolicy_(cocoa::appkit::NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular); - } - - let _ = window.show(); - let _ = window.unminimize(); - let _ = window.set_focus(); - - unsafe { - let ns_app = NSApplication::sharedApplication(nil); - ns_app.activateIgnoringOtherApps_(YES); - } - - println!("Window restored from Dock/Cmd+Tab"); - } - } - } - }); -} - -// Tauri command: 获取当前进程的代理设置(用于前端调试) -#[tauri::command] -fn get_current_proxy() -> Result, String> { - Ok(duckcoding::ProxyService::get_current_proxy()) -} - -// Add runtime command to re-apply proxy from saved config without recompiling -#[tauri::command] -fn apply_proxy_now() -> Result, String> { - let config_path = get_global_config_path()?; - if !config_path.exists() { - return Err("config not found".to_string()); - } - let content = std::fs::read_to_string(&config_path) - .map_err(|e| format!("Failed to read config: {}", e))?; - let cfg: GlobalConfig = - serde_json::from_str(&content).map_err(|e| format!("Failed to parse config: {}", e))?; - - duckcoding::ProxyService::apply_proxy_from_config(&cfg); - Ok(duckcoding::ProxyService::get_current_proxy()) -} - -#[derive(serde::Deserialize)] -struct ProxyTestConfig { - enabled: bool, - proxy_type: String, - host: String, - port: String, - username: Option, - password: Option, -} - -#[derive(serde::Serialize)] -struct TestProxyResult { - success: bool, - status: u16, - url: Option, - error: Option, -} - -#[tauri::command] -async fn test_proxy_request( - test_url: String, - proxy_config: ProxyTestConfig, -) -> Result { - // 根据代理配置构建客户端 - let client = if proxy_config.enabled { - // 构建代理 URL - let auth = if let (Some(username), Some(password)) = - (&proxy_config.username, &proxy_config.password) - { - if !username.is_empty() && !password.is_empty() { - format!("{}:{}@", username, password) - } else { - String::new() - } - } else { - String::new() - }; - - let scheme = match proxy_config.proxy_type.as_str() { - "socks5" => "socks5", - "https" => "https", - _ => "http", - }; - - let proxy_url = format!( - "{}://{}{}:{}", - scheme, auth, proxy_config.host, proxy_config.port - ); - - println!( - "Testing with proxy: {}", - proxy_url.replace(&auth, "***:***@") - ); // 隐藏密码 - - // 构建带代理的客户端 - match reqwest::Proxy::all(&proxy_url) { - Ok(proxy) => reqwest::Client::builder() - .proxy(proxy) - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| format!("Failed to build client with proxy: {}", e))?, - Err(e) => { - return Ok(TestProxyResult { - success: false, - status: 0, - url: None, - error: Some(format!("Invalid proxy URL: {}", e)), - }); - } - } - } else { - // 不使用代理的客户端 - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| format!("Failed to build client: {}", e))? - }; - - match client.get(&test_url).send().await { - Ok(resp) => { - let status = resp.status().as_u16(); - let url_ret = resp.url().as_str().to_string(); - Ok(TestProxyResult { - success: resp.status().is_success(), - status, - url: Some(url_ret), - error: None, - }) - } - Err(e) => Ok(TestProxyResult { - success: false, - status: 0, - url: None, - error: Some(e.to_string()), - }), - } -} - -// ==================== 更新管理相关命令 ==================== - -// 全局更新服务实例 -static UPDATE_SERVICE: std::sync::OnceLock> = - std::sync::OnceLock::new(); - -fn get_update_service() -> std::sync::Arc { - UPDATE_SERVICE - .get_or_init(|| { - let service = UpdateService::new(); - // 初始化更新服务 - let service_clone = service.clone(); - tokio::spawn(async move { - if let Err(e) = service_clone.initialize().await { - eprintln!("Failed to initialize update service: {}", e); - } - }); - std::sync::Arc::new(service) - }) - .clone() -} - -#[tauri::command] -async fn check_for_app_updates() -> Result { - let service = get_update_service(); - service - .check_for_updates() - .await - .map_err(|e| format!("Failed to check for updates: {}", e)) -} - -#[tauri::command] -async fn download_app_update(url: String, app: AppHandle) -> Result { - let service = get_update_service(); - let window = app - .get_webview_window("main") - .ok_or("Main window not found")?; - - let _service_clone = service.clone(); - let window_clone = window.clone(); - - service - .download_update(&url, move |progress| { - let _ = window_clone.emit("update-download-progress", &progress); - }) - .await - .map_err(|e| format!("Failed to download update: {}", e)) -} - -#[tauri::command] -async fn install_app_update(update_path: String) -> Result<(), String> { - let service = get_update_service(); - service - .install_update(&update_path) - .await - .map_err(|e| format!("Failed to install update: {}", e)) -} - -#[tauri::command] -async fn get_app_update_status() -> Result { - let service = get_update_service(); - Ok(service.get_status().await) -} - -#[tauri::command] -async fn rollback_app_update() -> Result<(), String> { - let service = get_update_service(); - service - .rollback_update() - .await - .map_err(|e| format!("Failed to rollback update: {}", e)) -} - -#[tauri::command] -async fn get_current_app_version() -> Result { - let service = get_update_service(); - Ok(service.get_current_version().to_string()) -} - -#[tauri::command] -async fn restart_app_for_update(app: AppHandle) -> Result<(), String> { - // 立即重启应用 - app.restart(); -} - -#[tauri::command] -async fn get_platform_info() -> Result { - let service = get_update_service(); - Ok(service.get_platform_info()) -} - -#[tauri::command] -async fn get_recommended_package_format() -> Result { - let service = get_update_service(); - Ok(service.get_recommended_package_format()) -} From 1fab3ed54d959f5bb3c55fcd7ee06569c6c1c796 Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:59:40 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E4=BB=A4=E7=89=8C=E5=88=9B=E5=BB=BA=E5=92=8C=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 后端变更 ### API 修正 - 修复 `create_provider_token` 命令返回类型(`RemoteToken` -> `()`) - 匹配 NEW API 实际行为(仅返回 `{ success, message }`,不返回令牌对象) ### 数据模型完善 - 扩展 `CreateRemoteTokenRequest` 字段: - `group_id` -> `group`(改为分组名称) - `quota` + `expire_days` -> `remain_quota` + `unlimited_quota` + `expired_time` - 新增:`model_limits_enabled`、`model_limits`、`allow_ips` - 同步更新单元测试 (`remote_token.rs`) ### 服务层适配 - `NewApiClient::create_token` 适配新请求体格式 - 文档注释说明 API 返回值特性 ## 前端变更 ### ImportFromProviderDialog 重写 - **双 Tab 设计**: - Tab A:选择现有令牌并导入 - Tab B:创建新令牌并直接导入 - 新增 `forwardRef` 支持外部触发一键生成 - 自动为无前缀令牌补充 `sk-` 前缀 - 支持从自定义创建跳转(`autoTriggerGenerate` prop) ### 新增组件(4 个) 1. **CreateCustomProfileDialog**:手动创建 Profile 对话框 - 支持一键配置快捷入口(跳转到导入对话框) - 可关闭的功能说明横幅 2. **DuckCodingGroupHint**:DuckCoding 分组说明组件 - 显示工具专用分组要求 - 集成控制台链接和一键生成按钮 3. **ProfileNameInput**:共享的 Profile 名称输入组件 - 统一的验证提示(禁止 `dc_proxy_` 前缀) 4. **TokenDetailCard**:令牌详情卡片 - 展示分组、额度、过期时间等信息 - 额度格式化(microdollars -> USD) ### 页面集成 - **ProfileManagementPage**: - 替换"手动创建"入口为 `CreateCustomProfileDialog` - 支持跨对话框流程(手动创建 -> 一键生成) - 使用 `useRef` 控制导入对话框的生成触发 - **ProviderManagementPage**: - 修复 React Fragment 警告(添加 `Fragment` 导入和 key) ### 类型定义同步 - `remote-token.ts`:更新 `CreateRemoteTokenRequest` 接口定义 - `token.ts`:修正 `createProviderToken` 返回类型(`RemoteToken` -> `void`) ## 测试覆盖 - ✅ 后端单元测试通过(`create_request_serialization`) - ✅ 前端类型检查通过 ## 影响范围 - 向后兼容:仅影响令牌创建流程,不影响现有导入功能 - 用户体验提升:提供更灵活的创建和导入选项 --- src-tauri/src/commands/token_commands.rs | 4 +- src-tauri/src/models/remote_token.rs | 34 +- src-tauri/src/services/new_api/client.rs | 23 +- src/lib/tauri-commands/token.ts | 6 +- .../components/CreateCustomProfileDialog.tsx | 300 +++++++ .../components/DuckCodingGroupHint.tsx | 85 ++ .../components/ImportFromProviderDialog.tsx | 760 +++++++++++++++--- .../components/ProfileNameInput.tsx | 38 + .../components/TokenDetailCard.tsx | 81 ++ src/pages/ProfileManagementPage/index.tsx | 43 +- src/pages/ProviderManagementPage/index.tsx | 8 +- src/types/remote-token.ts | 10 +- 12 files changed, 1219 insertions(+), 173 deletions(-) create mode 100644 src/pages/ProfileManagementPage/components/CreateCustomProfileDialog.tsx create mode 100644 src/pages/ProfileManagementPage/components/DuckCodingGroupHint.tsx create mode 100644 src/pages/ProfileManagementPage/components/ProfileNameInput.tsx create mode 100644 src/pages/ProfileManagementPage/components/TokenDetailCard.tsx diff --git a/src-tauri/src/commands/token_commands.rs b/src-tauri/src/commands/token_commands.rs index 5bfcba1..fc584eb 100644 --- a/src-tauri/src/commands/token_commands.rs +++ b/src-tauri/src/commands/token_commands.rs @@ -25,12 +25,12 @@ pub async fn fetch_provider_groups(provider: Provider) -> Result Result { +) -> Result<(), String> { let client = NewApiClient::new(provider).map_err(|e| e.to_string())?; client .create_token(request) diff --git a/src-tauri/src/models/remote_token.rs b/src-tauri/src/models/remote_token.rs index a182161..ce14342 100644 --- a/src-tauri/src/models/remote_token.rs +++ b/src-tauri/src/models/remote_token.rs @@ -73,12 +73,20 @@ pub struct RemoteTokenGroup { pub struct CreateRemoteTokenRequest { /// 令牌名称 pub name: String, - /// 分组 ID - pub group_id: String, - /// 初始额度(-1 表示无限) - pub quota: i64, - /// 过期天数(0 表示永不过期) - pub expire_days: i32, + /// 分组名称 + pub group: String, + /// 初始额度(token,500000 = 基准值) + pub remain_quota: i64, + /// 是否无限额度 + pub unlimited_quota: bool, + /// 过期时间(Unix 时间戳,-1 表示永不过期) + pub expired_time: i64, + /// 是否启用模型限制 + pub model_limits_enabled: bool, + /// 模型限制(逗号分隔的模型列表) + pub model_limits: String, + /// 允许的 IP 地址(逗号分隔) + pub allow_ips: String, } /// NEW API 通用响应结构 @@ -135,13 +143,19 @@ mod tests { fn test_create_request_serialization() { let request = CreateRemoteTokenRequest { name: "New Token".to_string(), - group_id: "group1".to_string(), - quota: -1, - expire_days: 30, + group: "group1".to_string(), + remain_quota: 500000, + unlimited_quota: false, + expired_time: 1735200000, + model_limits_enabled: false, + model_limits: String::new(), + allow_ips: String::new(), }; let json = serde_json::to_string(&request).unwrap(); assert!(json.contains("\"name\":\"New Token\"")); - assert!(json.contains("\"quota\":-1")); + assert!(json.contains("\"unlimited_quota\":false")); + assert!(json.contains("\"remain_quota\":500000")); + assert!(json.contains("\"group\":\"group1\"")); } } diff --git a/src-tauri/src/services/new_api/client.rs b/src-tauri/src/services/new_api/client.rs index 7a34d87..b6faf66 100644 --- a/src-tauri/src/services/new_api/client.rs +++ b/src-tauri/src/services/new_api/client.rs @@ -131,14 +131,20 @@ impl NewApiClient { Ok(groups) } - /// 创建新的远程令牌 - pub async fn create_token(&self, request: CreateRemoteTokenRequest) -> Result { + /// 创建新的远程令牌(返回值仅包含成功状态,不返回令牌对象) + pub async fn create_token(&self, request: CreateRemoteTokenRequest) -> Result<()> { let url = format!("{}/api/token", self.base_url()); + + // 构建请求体(所有字段都是必需的) let body = json!({ "name": request.name, - "group_id": request.group_id, - "quota": request.quota, - "expire_days": request.expire_days, + "group": request.group, + "remain_quota": request.remain_quota, + "unlimited_quota": request.unlimited_quota, + "expired_time": request.expired_time, + "model_limits_enabled": request.model_limits_enabled, + "model_limits": request.model_limits, + "allow_ips": request.allow_ips, }); let response = self @@ -157,7 +163,8 @@ impl NewApiClient { )); } - let api_response: NewApiResponse = response + // API 只返回 { success: true, message: "" },不返回令牌对象 + let api_response: NewApiResponse<()> = response .json() .await .map_err(|e| anyhow!("解析响应失败: {}", e))?; @@ -171,9 +178,7 @@ impl NewApiClient { )); } - api_response - .data - .ok_or_else(|| anyhow!("API 未返回令牌数据")) + Ok(()) } /// 删除远程令牌 diff --git a/src/lib/tauri-commands/token.ts b/src/lib/tauri-commands/token.ts index 0236ee1..6f0ce0a 100644 --- a/src/lib/tauri-commands/token.ts +++ b/src/lib/tauri-commands/token.ts @@ -21,13 +21,13 @@ export async function fetchProviderGroups(provider: Provider): Promise { - return invoke('create_provider_token', { provider, request }); +): Promise { + return invoke('create_provider_token', { provider, request }); } /** diff --git a/src/pages/ProfileManagementPage/components/CreateCustomProfileDialog.tsx b/src/pages/ProfileManagementPage/components/CreateCustomProfileDialog.tsx new file mode 100644 index 0000000..e09e4ea --- /dev/null +++ b/src/pages/ProfileManagementPage/components/CreateCustomProfileDialog.tsx @@ -0,0 +1,300 @@ +/** + * 自定义 Profile 创建对话框 + * + * 手动输入 API Key 和 Base URL 创建 Profile + */ + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2, Sparkles, X } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import type { ToolId } from '@/types/profile'; +import { ProfileNameInput } from './ProfileNameInput'; +import { pmListToolProfiles } from '@/lib/tauri-commands/profile'; +import { createCustomProfile } from '@/lib/tauri-commands/token'; + +interface CreateCustomProfileDialogProps { + /** 对话框打开状态 */ + open: boolean; + /** 对话框状态变更回调 */ + onOpenChange: (open: boolean) => void; + /** 当前工具 ID */ + toolId: ToolId; + /** 创建成功回调 */ + onSuccess: () => void; + /** 一键配置回调(打开从供应商导入对话框) */ + onQuickSetup: () => void; +} + +/** + * 自定义 Profile 创建对话框 + */ +export function CreateCustomProfileDialog({ + open, + onOpenChange, + toolId, + onSuccess, + onQuickSetup, +}: CreateCustomProfileDialogProps) { + const { toast } = useToast(); + + // 表单数据 + const [profileName, setProfileName] = useState(''); + const [baseUrl, setBaseUrl] = useState('https://jp.duckcoding.com/'); + const [apiKey, setApiKey] = useState(''); + const [wireApi, setWireApi] = useState('responses'); // Codex 特定 + const [model, setModel] = useState(''); // Gemini 特定 + + // UI 状态 + const [creating, setCreating] = useState(false); + const [bannerVisible, setBannerVisible] = useState(true); + + /** + * Dialog 打开时重置状态 + */ + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + // 重置表单 + setProfileName(''); + setBaseUrl('https://jp.duckcoding.com/'); + setApiKey(''); + setWireApi('responses'); + setModel(''); + // 重置横幅状态(每次打开时都显示) + setBannerVisible(true); + } + onOpenChange(isOpen); + }; + + /** + * 一键配置按钮点击 + */ + const handleQuickSetup = () => { + onOpenChange(false); // 关闭当前对话框 + onQuickSetup(); // 触发父组件打开供应商导入对话框 + }; + + /** + * 表单提交 + */ + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // 验证必填项 + if (!profileName.trim()) { + toast({ + title: '请输入 Profile 名称', + variant: 'destructive', + }); + return; + } + + if (!baseUrl.trim()) { + toast({ + title: '请输入 Base URL', + variant: 'destructive', + }); + return; + } + + if (!apiKey.trim()) { + toast({ + title: '请输入 API Key', + variant: 'destructive', + }); + return; + } + + // 检查保留前缀 + if (profileName.startsWith('dc_proxy_')) { + toast({ + title: '验证失败', + description: 'Profile 名称不能以 dc_proxy_ 开头(系统保留)', + variant: 'destructive', + }); + return; + } + + setCreating(true); + try { + // 检查是否已存在同名 Profile + const existingProfiles = await pmListToolProfiles(toolId); + if (existingProfiles.includes(profileName)) { + toast({ + title: '验证失败', + description: '该 Profile 名称已存在,请使用其他名称', + variant: 'destructive', + }); + setCreating(false); + return; + } + + // 构建额外配置 + const extraConfig: { wire_api?: string; model?: string } = {}; + if (toolId === 'codex') { + extraConfig.wire_api = wireApi; + } else if (toolId === 'gemini-cli' && model.trim()) { + extraConfig.model = model; + } + + // 调用创建 API + await createCustomProfile(toolId, profileName, apiKey, baseUrl, extraConfig); + + toast({ + title: '创建成功', + description: `Profile「${profileName}」已成功创建`, + }); + + onSuccess(); + onOpenChange(false); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + toast({ + title: '创建失败', + description: errorMsg, + variant: 'destructive', + }); + } finally { + setCreating(false); + } + }; + + return ( + + + + 创建自定义 Profile + 手动输入 API Key 和 Base URL 创建本地 Profile 配置 + + +
+ {/* 推荐横幅 */} + {bannerVisible && ( + + + +
+

推荐使用 DuckCoding 一键配置

+

+ 无需手动填写,自动获取最新 API Key 并导入为 Profile +

+ +
+ +
+
+ )} + + {/* Profile 名称 */} + + + {/* Base URL */} +
+ + setBaseUrl(e.target.value)} + placeholder="例如: https://api.example.com" + required + /> +

API 端点的基础 URL

+
+ + {/* API Key */} +
+ + setApiKey(e.target.value)} + placeholder="输入您的 API Key" + required + /> +

您的 API 访问密钥

+
+ + {/* Codex 特定配置 */} + {toolId === 'codex' && ( +
+ + +

选择 Codex API 类型

+
+ )} + + {/* Gemini 特定配置 */} + {toolId === 'gemini-cli' && ( +
+ + setModel(e.target.value)} + placeholder="例如: gemini-2.0-flash-exp" + /> +

指定模型名称(留空则使用原有配置)

+
+ )} + + + + + + +
+
+ ); +} diff --git a/src/pages/ProfileManagementPage/components/DuckCodingGroupHint.tsx b/src/pages/ProfileManagementPage/components/DuckCodingGroupHint.tsx new file mode 100644 index 0000000..703a118 --- /dev/null +++ b/src/pages/ProfileManagementPage/components/DuckCodingGroupHint.tsx @@ -0,0 +1,85 @@ +/** + * DuckCoding API Key 分组说明组件 + * + * 显示 DuckCoding 专用分组要求、控制台链接和一键生成按钮 + */ + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Info, Sparkles, Loader2, ExternalLink } from 'lucide-react'; +import type { ToolId } from '@/types/profile'; +import { groupNameMap } from '@/utils/constants'; +import { openExternalLink } from '@/utils/formatting'; + +interface DuckCodingGroupHintProps { + /** 当前工具ID */ + toolId: ToolId; + /** 一键生成按钮点击回调 */ + onGenerateClick: () => void; + /** 是否正在生成 */ + generating: boolean; +} + +/** + * DuckCoding 分组说明组件 + */ +export function DuckCodingGroupHint({ + toolId, + onGenerateClick, + generating, +}: DuckCodingGroupHintProps) { + return ( + + + DuckCoding API Key 分组说明 + + {/* 当前工具需要使用的分组 */} +
+

当前工具需要使用:

+

+ {groupNameMap[toolId]} 分组 +

+
+ + {/* 分组使用规则 */} +
    +
  • 每个工具必须使用其专用分组的 API Key
  • +
  • API Key 不能混用
  • +
+ + {/* 获取 API Key 指引 */} +
+

获取 API Key:

+ +
+ + {/* 一键生成按钮 */} + +
+
+ ); +} diff --git a/src/pages/ProfileManagementPage/components/ImportFromProviderDialog.tsx b/src/pages/ProfileManagementPage/components/ImportFromProviderDialog.tsx index 70c03c1..eabb047 100644 --- a/src/pages/ProfileManagementPage/components/ImportFromProviderDialog.tsx +++ b/src/pages/ProfileManagementPage/components/ImportFromProviderDialog.tsx @@ -1,15 +1,18 @@ /** - * 从供应商导入 Profile 对话框 + * 从供应商导入 Profile 对话框(完全重写版本) + * + * 支持两种导入方式: + * - Tab A:选择现有令牌并导入 + * - Tab B:创建新令牌并直接导入 */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, - DialogFooter, } from '@/components/ui/dialog'; import { Select, @@ -18,59 +21,103 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; +import { DialogFooter } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; import { Loader2, Download } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import type { ToolId } from '@/types/profile'; import type { Provider } from '@/types/provider'; -import type { RemoteToken } from '@/types/remote-token'; +import type { RemoteToken, RemoteTokenGroup, CreateRemoteTokenRequest } from '@/types/remote-token'; import { listProviders } from '@/lib/tauri-commands/provider'; -import { fetchProviderTokens, importTokenAsProfile } from '@/lib/tauri-commands/token'; +import { + fetchProviderTokens, + fetchProviderGroups, + importTokenAsProfile, + createProviderToken, +} from '@/lib/tauri-commands/token'; import { pmListToolProfiles } from '@/lib/tauri-commands/profile'; +import { generateApiKeyForTool, getGlobalConfig } from '@/lib/tauri-commands'; +import { DuckCodingGroupHint } from './DuckCodingGroupHint'; +import { TokenDetailCard } from './TokenDetailCard'; +import { ProfileNameInput } from './ProfileNameInput'; interface ImportFromProviderDialogProps { + /** 对话框打开状态 */ open: boolean; + /** 对话框状态变更回调 */ onOpenChange: (open: boolean) => void; + /** 当前工具 ID */ toolId: ToolId; + /** 导入成功回调 */ onSuccess: () => void; + /** 自动触发一键生成(从手动创建跳转时) */ + autoTriggerGenerate?: boolean; +} + +export interface ImportFromProviderDialogRef { + triggerGenerate: () => void; } /** * 从供应商导入 Profile 对话框 */ -export function ImportFromProviderDialog({ - open, - onOpenChange, - toolId, - onSuccess, -}: ImportFromProviderDialogProps) { +export const ImportFromProviderDialog = forwardRef< + ImportFromProviderDialogRef, + ImportFromProviderDialogProps +>(({ open, onOpenChange, toolId, onSuccess, autoTriggerGenerate }, ref) => { const { toast } = useToast(); - const [loading, setLoading] = useState(false); - const [importing, setImporting] = useState(false); - // 供应商和令牌数据 + // ==================== 数据状态 ==================== const [providers, setProviders] = useState([]); const [tokens, setTokens] = useState([]); + const [tokenGroups, setTokenGroups] = useState([]); - // 表单数据 - const [selectedProviderId, setSelectedProviderId] = useState(''); - const [selectedTokenId, setSelectedTokenId] = useState(null); + // ==================== 选择状态 ==================== + const [providerId, setProviderId] = useState(''); + const [tokenId, setTokenId] = useState(null); + const [activeTab, setActiveTab] = useState<'select' | 'create'>('select'); + + // ==================== Tab B 表单状态 ==================== + const [newTokenName, setNewTokenName] = useState(''); + const [groupId, setGroupId] = useState(''); + const [quota, setQuota] = useState(-1); + const [expireDays, setExpireDays] = useState(0); + const [unlimitedQuota, setUnlimitedQuota] = useState(true); + const [unlimitedExpire, setUnlimitedExpire] = useState(true); + + // ==================== 共享状态 ==================== const [profileName, setProfileName] = useState(''); + // ==================== 加载状态 ==================== + const [loadingProviders, setLoadingProviders] = useState(false); + const [loadingTokens, setLoadingTokens] = useState(false); + const [loadingGroups, setLoadingGroups] = useState(false); + const [generatingKey, setGeneratingKey] = useState(false); + const [importing, setImporting] = useState(false); + const [creating, setCreating] = useState(false); + // 获取当前选中的供应商和令牌 - const selectedProvider = providers.find((p) => p.id === selectedProviderId); - const selectedToken = tokens.find((t) => t.id === selectedTokenId); + const selectedProvider = providers.find((p) => p.id === providerId); + const selectedToken = tokens.find((t) => t.id === tokenId); /** * 加载供应商列表 */ const loadProviders = async () => { try { - setLoading(true); + setLoadingProviders(true); const result = await listProviders(); setProviders(result); + + // 默认选中 duckcoding 供应商 + const duckcodingProvider = result.find((p) => p.id === 'duckcoding'); + if (duckcodingProvider) { + setProviderId('duckcoding'); + } } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); toast({ @@ -79,18 +126,23 @@ export function ImportFromProviderDialog({ variant: 'destructive', }); } finally { - setLoading(false); + setLoadingProviders(false); } }; /** - * 加载选中供应商的令牌列表 + * 加载令牌列表 */ const loadTokens = async (provider: Provider) => { try { - setLoading(true); + setLoadingTokens(true); const result = await fetchProviderTokens(provider); - setTokens(result); + // 自动为没有 sk- 前缀的令牌添加前缀 + const normalizedTokens = result.map((token) => ({ + ...token, + key: token.key.startsWith('sk-') ? token.key : `sk-${token.key}`, + })); + setTokens(normalizedTokens); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); toast({ @@ -100,36 +152,71 @@ export function ImportFromProviderDialog({ }); setTokens([]); } finally { - setLoading(false); + setLoadingTokens(false); } }; /** - * Dialog 打开时加载供应商列表 + * 加载分组列表(Tab B 使用) + */ + const loadGroups = async (provider: Provider) => { + try { + setLoadingGroups(true); + const result = await fetchProviderGroups(provider); + setTokenGroups(result); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + toast({ + title: '加载分组失败', + description: errorMsg, + variant: 'destructive', + }); + setTokenGroups([]); + } finally { + setLoadingGroups(false); + } + }; + + /** + * Dialog 打开时初始化 */ useEffect(() => { if (open) { - loadProviders(); - // 重置表单 - setSelectedProviderId(''); - setSelectedTokenId(null); + // 重置所有状态 + setProviderId(''); + setTokenId(null); setProfileName(''); setTokens([]); + setTokenGroups([]); + setActiveTab('select'); + setNewTokenName(''); + setGroupId(''); + setQuota(-1); + setExpireDays(0); + setUnlimitedQuota(true); + setUnlimitedExpire(true); + + // 加载供应商列表 + loadProviders(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); /** - * 供应商变更时加载令牌列表 + * 供应商变更时加载令牌和分组 */ useEffect(() => { if (selectedProvider) { loadTokens(selectedProvider); - setSelectedTokenId(null); + loadGroups(selectedProvider); + setTokenId(null); } else { setTokens([]); - setSelectedTokenId(null); + setTokenGroups([]); + setTokenId(null); } - }, [selectedProviderId]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [providerId]); /** * 令牌变更时自动填充 Profile 名称 @@ -138,14 +225,105 @@ export function ImportFromProviderDialog({ if (selectedToken && !profileName) { setProfileName(selectedToken.name + '_profile'); } - }, [selectedTokenId]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tokenId]); + + /** + * 暴露给父组件的方法 + */ + useImperativeHandle(ref, () => ({ + triggerGenerate: handleGenerateApiKey, + })); + + /** + * 自动触发一键生成(从手动创建跳转时) + */ + useEffect(() => { + if (open && autoTriggerGenerate && selectedProvider?.id === 'duckcoding') { + // 延迟执行,确保对话框已完全渲染 + const timer = setTimeout(() => { + handleGenerateApiKey(); + }, 300); + return () => clearTimeout(timer); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, autoTriggerGenerate]); /** - * 提交导入 + * 一键生成 API Key */ - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const handleGenerateApiKey = async () => { + if (!selectedProvider) return; + + try { + setGeneratingKey(true); + + // 检查全局配置 + const config = await getGlobalConfig(); + if (!config?.user_id || !config?.system_token) { + toast({ + title: '缺少配置', + description: '请先在设置中配置用户 ID 和系统访问令牌', + variant: 'destructive', + }); + window.dispatchEvent(new CustomEvent('navigate-to-settings')); + return; + } + + // 生成 API Key + const result = await generateApiKeyForTool(toolId); + + if (result.success && result.api_key) { + toast({ + title: '生成成功', + description: 'API Key 已自动创建,正在刷新令牌列表...', + }); + // 重新加载令牌列表并获取最新数据 + const updatedTokens = await fetchProviderTokens(selectedProvider); + // 自动为没有 sk- 前缀的令牌添加前缀 + const normalizedTokens = updatedTokens.map((token) => ({ + ...token, + key: token.key.startsWith('sk-') ? token.key : `sk-${token.key}`, + })); + setTokens(normalizedTokens); + + // 自动选中新生成的令牌(根据返回的 API Key 匹配) + // 注意:返回的 api_key 可能没有 sk- 前缀,需要标准化后再匹配 + const normalizedApiKey = result.api_key.startsWith('sk-') + ? result.api_key + : `sk-${result.api_key}`; + if (normalizedTokens.length > 0) { + const newToken = normalizedTokens.find((t) => t.key === normalizedApiKey); + if (newToken) { + setTokenId(newToken.id); + } else { + // 回退:选择列表中的第一个 + setTokenId(normalizedTokens[0].id); + } + } + } else { + toast({ + title: '生成失败', + description: result.message || '未知错误', + variant: 'destructive', + }); + } + } catch (error) { + toast({ + title: '生成失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setGeneratingKey(false); + } + }; + + /** + * Tab A 导入逻辑 + */ + const handleImportFromSelect = async () => { if (!selectedProvider || !selectedToken) { toast({ title: '请选择供应商和令牌', @@ -189,8 +367,7 @@ export function ImportFromProviderDialog({ await importTokenAsProfile(selectedProvider, selectedToken, toolId, profileName); toast({ title: '导入成功', - description: - '令牌「' + selectedToken.name + '」已成功导入为 Profile「' + profileName + '」', + description: `令牌「${selectedToken.name}」已成功导入为 Profile「${profileName}」`, }); onSuccess(); onOpenChange(false); @@ -206,24 +383,180 @@ export function ImportFromProviderDialog({ } }; + /** + * Tab B 令牌名称变更时自动填充 Profile 名称 + */ + useEffect(() => { + if (activeTab === 'create' && newTokenName && !profileName) { + setProfileName(newTokenName + '_profile'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [newTokenName, activeTab]); + + /** + * Tab B 创建并导入逻辑 + */ + const handleCreateAndImport = async () => { + if (!selectedProvider) { + toast({ + title: '请选择供应商', + variant: 'destructive', + }); + return; + } + + // 验证必填项 + if (!newTokenName.trim()) { + toast({ + title: '请输入令牌名称', + variant: 'destructive', + }); + return; + } + + if (!groupId) { + toast({ + title: '请选择分组', + variant: 'destructive', + }); + return; + } + + if (!profileName.trim()) { + toast({ + title: '请输入 Profile 名称', + variant: 'destructive', + }); + return; + } + + // 检查保留前缀 + if (profileName.startsWith('dc_proxy_')) { + toast({ + title: '验证失败', + description: 'Profile 名称不能以 dc_proxy_ 开头(系统保留)', + variant: 'destructive', + }); + return; + } + + setCreating(true); + try { + // 检查是否已存在同名 Profile + const existingProfiles = await pmListToolProfiles(toolId); + if (existingProfiles.includes(profileName)) { + toast({ + title: '验证失败', + description: '该 Profile 名称已存在,请使用其他名称', + variant: 'destructive', + }); + setCreating(false); + return; + } + + // 计算过期时间(Unix 时间戳) + const expiredTime = unlimitedExpire + ? -1 // -1 表示永不过期 + : Math.floor(Date.now() / 1000) + expireDays * 24 * 60 * 60; + + // 计算额度(token) + const remainQuota = unlimitedQuota ? 500000 : quota * 500000; + + // 构建创建请求(所有字段都是必需的) + const request: CreateRemoteTokenRequest = { + name: newTokenName, + group: groupId, + remain_quota: remainQuota, + unlimited_quota: unlimitedQuota, + expired_time: expiredTime, + model_limits_enabled: false, + model_limits: '', + allow_ips: '', + }; + + // 调用创建令牌 API(返回 void) + await createProviderToken(selectedProvider, request); + + toast({ + title: '创建成功', + description: `令牌「${newTokenName}」已成功创建,正在获取令牌详情...`, + }); + + // 等待 500ms 确保服务器处理完成 + await new Promise((resolve) => setTimeout(resolve, 500)); + + // 重新获取令牌列表 + const updatedTokens = await fetchProviderTokens(selectedProvider); + // 自动为没有 sk- 前缀的令牌添加前缀 + const normalizedTokens = updatedTokens.map((token) => ({ + ...token, + key: token.key.startsWith('sk-') ? token.key : `sk-${token.key}`, + })); + + // 按 ID 降序排序,找到名称匹配的第一个(最新创建的) + const sortedTokens = normalizedTokens + .filter((t) => t.name === newTokenName) + .sort((a, b) => b.id - a.id); + + if (sortedTokens.length === 0) { + toast({ + title: '查找失败', + description: `无法找到刚创建的令牌「${newTokenName}」,请手动刷新列表`, + variant: 'destructive', + }); + setCreating(false); + return; + } + + const newToken = sortedTokens[0]; + + // 直接导入为 Profile + await importTokenAsProfile(selectedProvider, newToken, toolId, profileName); + + toast({ + title: '导入成功', + description: `令牌「${newToken.name}」已成功导入为 Profile「${profileName}」`, + }); + + onSuccess(); + onOpenChange(false); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + toast({ + title: '创建失败', + description: errorMsg, + variant: 'destructive', + }); + } finally { + setCreating(false); + } + }; + return ( - + 从供应商导入 Profile - 选择供应商和令牌,一键导入为本地 Profile 配置 + + 选择供应商和令牌,或创建新令牌并一键导入为本地 Profile 配置 + -
- {/* 选择供应商 */} +
+ {/* 供应商选择器 */}
- - {providers.length === 0 ? ( + {loadingProviders ? ( +
+ + 加载中... +
+ ) : providers.length === 0 ? (
暂无可用供应商
@@ -239,98 +572,261 @@ export function ImportFromProviderDialog({

选择要从哪个供应商导入令牌

- {/* 选择令牌 */} -
- - setTokenId(Number(v))} + disabled={!selectedProvider || loadingTokens} + > + + + + + {loadingTokens ? ( +
+ + 加载中...
- - )) - )} -
- -

选择要导入的令牌

-
+ ) : tokens.length === 0 ? ( +
+ 该供应商暂无可用令牌 +
+ ) : ( + tokens.map((token) => ( + +
+ {token.name} + + {token.unlimited_quota + ? '无限' + : `$${(token.remain_quota / 1000000).toFixed(2)}`} + +
+
+ )) + )} + + +

选择要导入的令牌

+
+ + {/* 令牌详情卡片 */} + {selectedToken && ( + g.id === selectedToken.group)} + /> + )} + + {/* Profile 名称输入 */} + + + {/* 导入按钮 */} + + + + + + + {/* Tab B: 创建令牌 */} + + {/* 令牌名称 */} +
+ + setNewTokenName(e.target.value)} + placeholder="例如: my_api_key" + disabled={!selectedProvider} + /> +

为新令牌设置一个名称

+
- {/* 令牌信息预览 */} - {selectedToken && ( -
-
- 令牌名称: - {selectedToken.name} + {/* 分组选择器 */} +
+ + +

选择令牌所属分组

-
- 分组: - {selectedToken.group} + + {/* 额度设置 */} +
+
+ +
+ setUnlimitedQuota(checked === true)} + /> + +
+
+ setQuota(Number(e.target.value))} + placeholder="例如: 100" + disabled={unlimitedQuota} + /> +

+ 设置令牌的使用限额 +

-
- 剩余额度: - - {selectedToken.unlimited_quota - ? '无限' - : '$' + (selectedToken.remain_quota / 1000000).toFixed(2)} - + + {/* 有效期设置 */} +
+
+ +
+ setUnlimitedExpire(checked === true)} + /> + +
+
+ setExpireDays(Number(e.target.value))} + placeholder="例如: 365" + disabled={unlimitedExpire} + /> +

设置令牌的有效期(0 表示永不过期)

-
- )} - {/* Profile 名称 */} -
- - setProfileName(e.target.value)} - placeholder="例如:my_api_profile" - required - /> -

为导入的 Profile 设置一个本地名称

-
+ {/* Profile 名称输入 */} + - - - - - + {/* 创建并导入按钮 */} + + + + + + +
); -} +}); + +ImportFromProviderDialog.displayName = 'ImportFromProviderDialog'; diff --git a/src/pages/ProfileManagementPage/components/ProfileNameInput.tsx b/src/pages/ProfileManagementPage/components/ProfileNameInput.tsx new file mode 100644 index 0000000..2c81610 --- /dev/null +++ b/src/pages/ProfileManagementPage/components/ProfileNameInput.tsx @@ -0,0 +1,38 @@ +/** + * Profile 名称输入组件 + * + * 共享的 Profile 名称输入框,提供验证提示 + */ + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +interface ProfileNameInputProps { + /** 输入值 */ + value: string; + /** 值变更回调 */ + onChange: (value: string) => void; + /** 占位符文本 */ + placeholder?: string; +} + +/** + * Profile 名称输入组件 + */ +export function ProfileNameInput({ value, onChange, placeholder }: ProfileNameInputProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder || '例如: my_profile'} + required + /> +

+ 为 Profile 设置一个本地名称(不能以 dc_proxy_ 开头) +

+
+ ); +} diff --git a/src/pages/ProfileManagementPage/components/TokenDetailCard.tsx b/src/pages/ProfileManagementPage/components/TokenDetailCard.tsx new file mode 100644 index 0000000..4a9553b --- /dev/null +++ b/src/pages/ProfileManagementPage/components/TokenDetailCard.tsx @@ -0,0 +1,81 @@ +/** + * 令牌详情卡片组件 + * + * 展示选中令牌的详细信息(分组、额度、过期时间等) + */ + +import type { RemoteToken, RemoteTokenGroup } from '@/types/remote-token'; + +interface TokenDetailCardProps { + /** 令牌对象 */ + token: RemoteToken; + /** 令牌所属分组(可选) */ + group?: RemoteTokenGroup; +} + +/** + * 令牌详情卡片 + */ +export function TokenDetailCard({ token, group }: TokenDetailCardProps) { + /** + * 格式化过期时间 + */ + const formatExpireTime = (timestamp: number): string => { + if (timestamp === 0) return '永不过期'; + return new Date(timestamp * 1000).toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + }; + + /** + * 格式化额度(microdollars 转美元) + */ + const formatQuota = (microdollars: number): string => { + return `$${(microdollars / 1000000).toFixed(2)}`; + }; + + return ( +
+ {/* 令牌名称 */} +
+ 令牌名称: + {token.name} +
+ + {/* 分组信息 */} +
+ 分组信息: +
+

{token.group}

+ {group && ( +

+ {group.desc} (倍率: {group.ratio}) +

+ )} +
+
+ + {/* 剩余额度 */} +
+ 剩余额度: + + {token.unlimited_quota ? '无限' : formatQuota(token.remain_quota)} + +
+ + {/* 已用额度 */} +
+ 已用额度: + {formatQuota(token.used_quota)} +
+ + {/* 过期时间 */} +
+ 过期时间: + {formatExpireTime(token.expired_time)} +
+
+ ); +} diff --git a/src/pages/ProfileManagementPage/index.tsx b/src/pages/ProfileManagementPage/index.tsx index dc4f571..6fc7e8c 100644 --- a/src/pages/ProfileManagementPage/index.tsx +++ b/src/pages/ProfileManagementPage/index.tsx @@ -2,7 +2,7 @@ * Profile 配置管理页面 */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { RefreshCw, Loader2, HelpCircle, Plus, Download, ChevronDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -24,6 +24,7 @@ import { ProfileCard } from './components/ProfileCard'; import { ProfileEditor } from './components/ProfileEditor'; import { ActiveProfileCard } from './components/ActiveProfileCard'; import { ImportFromProviderDialog } from './components/ImportFromProviderDialog'; +import { CreateCustomProfileDialog } from './components/CreateCustomProfileDialog'; import { useProfileManagement } from './hooks/useProfileManagement'; import type { ToolId, ProfileFormData, ProfileDescriptor } from '@/types/profile'; import { logoMap } from '@/utils/constants'; @@ -48,19 +49,17 @@ export default function ProfileManagementPage() { const [editingProfile, setEditingProfile] = useState(null); const [helpDialogOpen, setHelpDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false); + const [customProfileDialogOpen, setCustomProfileDialogOpen] = useState(false); + const [autoTriggerGenerate, setAutoTriggerGenerate] = useState(false); + + // ImportFromProviderDialog ref 用于触发一键生成 + const importDialogRef = useRef<{ triggerGenerate: () => void } | null>(null); // 初始化加载透明代理状态 useEffect(() => { loadAllProxyStatus(); }, [loadAllProxyStatus]); - // 打开创建对话框 - const handleCreateProfile = () => { - setEditorMode('create'); - setEditingProfile(null); - setEditorOpen(true); - }; - // 打开编辑对话框 const handleEditProfile = (profile: ProfileDescriptor) => { setEditorMode('edit'); @@ -183,7 +182,7 @@ export default function ProfileManagementPage() { - + setCustomProfileDialogOpen(true)}> 手动创建 @@ -233,13 +232,37 @@ export default function ProfileManagementPage() { {/* 帮助弹窗 */} + {/* 自定义 Profile 创建对话框 */} + { + setCustomProfileDialogOpen(false); + refresh(); + }} + onQuickSetup={() => { + setCustomProfileDialogOpen(false); + setAutoTriggerGenerate(true); + setImportDialogOpen(true); + }} + /> + {/* 从供应商导入对话框 */} { + setImportDialogOpen(open); + if (!open) { + setAutoTriggerGenerate(false); + } + }} toolId={selectedTab} + autoTriggerGenerate={autoTriggerGenerate} onSuccess={() => { setImportDialogOpen(false); + setAutoTriggerGenerate(false); refresh(); }} /> diff --git a/src/pages/ProviderManagementPage/index.tsx b/src/pages/ProviderManagementPage/index.tsx index c6409db..14c8896 100644 --- a/src/pages/ProviderManagementPage/index.tsx +++ b/src/pages/ProviderManagementPage/index.tsx @@ -10,7 +10,7 @@ import { TableRow, } from '@/components/ui/table'; import { Building2, Plus, Pencil, Trash2, Loader2, ChevronDown, ChevronRight } from 'lucide-react'; -import { useState } from 'react'; +import { useState, Fragment } from 'react'; import type { Provider } from '@/lib/tauri-commands'; import { useToast } from '@/hooks/use-toast'; import { useProviderManagement } from './hooks/useProviderManagement'; @@ -162,8 +162,8 @@ export function ProviderManagementPage() { {providers.map((provider) => { const isExpanded = expandedProviderId === provider.id; return ( - <> - + + {/* 展开按钮 */}