diff --git a/package-lock.json b/package-lock.json index f5b8c74..4f20188 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "duckcoding-setup", "version": "1.5.0", - "license": "MIT", + "license": "AGPL-3.0-only", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -34,6 +34,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "ipaddr.js": "^2.3.0", "lucide-react": "^0.552.0", "react": "^19.2.1", "react-dom": "^19.2.1", @@ -5790,6 +5791,15 @@ "node": ">=12" } }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", diff --git a/package.json b/package.json index 5583e2a..c794a88 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "ipaddr.js": "^2.3.0", "lucide-react": "^0.552.0", "react": "^19.2.1", "react-dom": "^19.2.1", 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/provider_commands.rs b/src-tauri/src/commands/provider_commands.rs index 5dc656d..3adc6af 100644 --- a/src-tauri/src/commands/provider_commands.rs +++ b/src-tauri/src/commands/provider_commands.rs @@ -26,6 +26,73 @@ impl Default for ProviderManagerState { } } +/// API 地址信息 +#[derive(serde::Serialize)] +pub struct ApiInfo { + pub url: String, + pub description: String, +} + +/// 获取供应商的 API 地址列表 +/// 从 {website_url}/api/status 获取 data.api_info 数组 +/// 失败时返回空数组(降级处理) +#[tauri::command] +pub async fn fetch_provider_api_addresses(website_url: String) -> Result, String> { + use reqwest::Client; + use std::time::Duration; + + // 构建 API 端点 + let api_url = format!("{}/api/status", website_url.trim_end_matches('/')); + + // 发送请求 + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; + + let response = client.get(&api_url).send().await; + + // 请求失败时返回空数组(降级) + let response = match response { + Ok(resp) => resp, + Err(_) => return Ok(vec![]), + }; + + // 解析 JSON + let json_result = response.json::().await; + let json = match json_result { + Ok(j) => j, + Err(_) => return Ok(vec![]), + }; + + // 提取 data.api_info 数组 + let api_info_array = json + .get("data") + .and_then(|data| data.get("api_info")) + .and_then(|info| info.as_array()); + + let api_info_array = match api_info_array { + Some(arr) => arr, + None => return Ok(vec![]), + }; + + // 转换为 ApiInfo 结构体 + let mut result = Vec::new(); + for item in api_info_array { + if let (Some(url), Some(description)) = ( + item.get("url").and_then(|u| u.as_str()), + item.get("description").and_then(|d| d.as_str()), + ) { + result.push(ApiInfo { + url: url.to_string(), + description: description.to_string(), + }); + } + } + + Ok(result) +} + /// 列出所有供应商 #[tauri::command] pub async fn list_providers( @@ -159,6 +226,27 @@ pub async fn validate_provider_config(provider: Provider) -> Result().await; match json_result { Ok(json) => { + // 检查响应体中的 success 字段 + let api_success = json + .get("success") + .and_then(|s| s.as_bool()) + .unwrap_or(true); // 没有 success 字段时默认为 true(兼容不同 API) + + if !api_success { + // API 返回 success: false,提取错误信息 + let error_msg = json + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("API 验证失败") + .to_string(); + + return Ok(ValidationResult { + success: false, + username: None, + error: Some(error_msg), + }); + } + // 尝试从响应中提取用户名 (假设在 data.username 或 username 字段) let username = json .get("data") diff --git a/src-tauri/src/commands/token_commands.rs b/src-tauri/src/commands/token_commands.rs new file mode 100644 index 0000000..bd95837 --- /dev/null +++ b/src-tauri/src/commands/token_commands.rs @@ -0,0 +1,342 @@ +// Token Management Commands +// +// NEW API 令牌管理相关命令 + +use ::duckcoding::models::provider::Provider; +use ::duckcoding::models::remote_token::{ + CreateRemoteTokenRequest, RemoteToken, RemoteTokenGroup, UpdateRemoteTokenRequest, +}; +use ::duckcoding::services::profile_manager::types::TokenImportStatus; +use ::duckcoding::services::{ + ClaudeProfile, CodexProfile, GeminiProfile, NewApiClient, ProfileSource, +}; +use anyhow::Result; +use chrono::Utc; +use tauri::State; + +/// 检测令牌是否已导入到任何工具 +#[tauri::command] +pub async fn check_token_import_status( + profile_manager: State<'_, crate::commands::profile_commands::ProfileManagerState>, + provider_id: String, + remote_token_id: i64, +) -> Result, String> { + let manager = profile_manager.manager.read().await; + manager + .check_import_status(&provider_id, remote_token_id) + .map_err(|e| e.to_string()) +} + +/// 获取指定供应商的远程令牌列表 +#[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<(), String> { + 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()) +} + +/// 更新供应商的远程令牌(完整版本,支持所有字段) +#[tauri::command] +pub async fn update_provider_token_full( + provider: Provider, + token_id: i64, + request: UpdateRemoteTokenRequest, +) -> Result { + let client = NewApiClient::new(provider).map_err(|e| e.to_string())?; + client + .update_token_full(token_id, request) + .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 + // 优先使用 api_address,未设置时使用 website_url + let api_key = remote_token.key.clone(); + let base_url = provider + .api_address + .clone() + .unwrap_or(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(), + api_address: None, + 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..53357cf 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -376,6 +376,17 @@ fn main() { update_provider, delete_provider, validate_provider_config, + fetch_provider_api_addresses, + // 令牌资产管理命令(NEW API 集成) + fetch_provider_tokens, + fetch_provider_groups, + create_provider_token, + delete_provider_token, + update_provider_token, + update_provider_token_full, + import_token_as_profile, + create_custom_profile, + check_token_import_status, // 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/provider.rs b/src-tauri/src/models/provider.rs index 5856b03..2a86080 100644 --- a/src-tauri/src/models/provider.rs +++ b/src-tauri/src/models/provider.rs @@ -13,6 +13,9 @@ pub struct Provider { pub name: String, /// 官网地址 pub website_url: String, + /// API 地址(可选,优先于 website_url 用于 API 调用) + #[serde(skip_serializing_if = "Option::is_none")] + pub api_address: Option, /// 用户ID pub user_id: String, /// 系统访问令牌 @@ -47,6 +50,7 @@ impl Default for ProviderStore { id: "duckcoding".to_string(), name: "DuckCoding".to_string(), website_url: "https://duckcoding.com".to_string(), + api_address: Some("https://jp.duckcoding.com".to_string()), user_id: String::new(), access_token: String::new(), username: None, @@ -79,6 +83,7 @@ mod tests { id: "test".to_string(), name: "Test Provider".to_string(), website_url: "https://test.com".to_string(), + api_address: Some("https://api.test.com".to_string()), user_id: "12345".to_string(), access_token: "token123".to_string(), username: Some("testuser".to_string()), @@ -92,6 +97,7 @@ mod tests { assert_eq!(deserialized.id, provider.id); assert_eq!(deserialized.name, provider.name); + assert_eq!(deserialized.api_address, provider.api_address); assert_eq!(deserialized.username, provider.username); } } diff --git a/src-tauri/src/models/remote_token.rs b/src-tauri/src/models/remote_token.rs new file mode 100644 index 0000000..f051d3e --- /dev/null +++ b/src-tauri/src/models/remote_token.rs @@ -0,0 +1,182 @@ +// 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, + /// 分组名称 + 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, +} + +/// 更新远程令牌请求(支持完整字段更新) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateRemoteTokenRequest { + /// 令牌名称 + pub name: String, + /// 分组名称 + 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 地址(换行符分隔,支持 CIDR 表达式) + pub allow_ips: String, +} + +/// 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: "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("\"unlimited_quota\":false")); + assert!(json.contains("\"remain_quota\":500000")); + assert!(json.contains("\"group\":\"group1\"")); + } +} 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..29dc95e --- /dev/null +++ b/src-tauri/src/services/new_api/client.rs @@ -0,0 +1,366 @@ +// NEW API Client +// +// NEW API 客户端服务,用于与供应商的 API 交互 + +use crate::models::provider::Provider; +use crate::models::remote_token::{ + CreateRemoteTokenRequest, NewApiResponse, RemoteToken, RemoteTokenGroup, RemoteTokenGroupInfo, + TokenListData, UpdateRemoteTokenRequest, +}; +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()) + )); + } + + // 标准化 API Key,确保所有令牌都有 sk- 前缀 + let mut tokens = api_response.data.map(|d| d.items).unwrap_or_default(); + for token in &mut tokens { + if !token.key.starts_with("sk-") { + token.key = format!("sk-{}", token.key); + } + } + + Ok(tokens) + } + + /// 获取所有令牌分组 + 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": 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 + .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() + )); + } + + // API 只返回 { success: true, message: "" },不返回令牌对象 + 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 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 未返回令牌数据")) + } + + /// 更新远程令牌信息(完整版本,支持所有字段) + pub async fn update_token_full( + &self, + token_id: i64, + request: UpdateRemoteTokenRequest, + ) -> Result { + let url = format!("{}/api/token/{}", self.base_url(), token_id); + + // 构建请求体(所有字段) + let body = json!({ + "name": request.name, + "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 + .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(), + api_address: None, + 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(), + api_address: None, + 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..9f1c376 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, } }; @@ -593,6 +599,87 @@ impl ProfileManager { Ok(()) } + // ==================== 导入状态检测 ==================== + + /// 检测令牌是否已导入到任何工具 + /// + /// 遍历所有工具的 Profile,检查是否存在相同 provider_id 和 remote_token_id 的导入记录 + pub fn check_import_status( + &self, + provider_id: &str, + remote_token_id: i64, + ) -> Result> { + const TOOLS: [&str; 3] = ["claude-code", "codex", "gemini-cli"]; + let store = self.load_profiles_store()?; + let mut results = Vec::new(); + + for &tool_id in &TOOLS { + let mut is_imported = false; + let mut imported_profile_name = None; + + // 根据工具类型检查对应的 Profile 集合 + match tool_id { + "claude-code" => { + for (name, profile) in &store.claude_code { + if let ProfileSource::ImportedFromProvider { + provider_id: pid, + remote_token_id: tid, + .. + } = &profile.source + { + if pid == provider_id && *tid == remote_token_id { + is_imported = true; + imported_profile_name = Some(name.clone()); + break; + } + } + } + } + "codex" => { + for (name, profile) in &store.codex { + if let ProfileSource::ImportedFromProvider { + provider_id: pid, + remote_token_id: tid, + .. + } = &profile.source + { + if pid == provider_id && *tid == remote_token_id { + is_imported = true; + imported_profile_name = Some(name.clone()); + break; + } + } + } + } + "gemini-cli" => { + for (name, profile) in &store.gemini_cli { + if let ProfileSource::ImportedFromProvider { + provider_id: pid, + remote_token_id: tid, + .. + } = &profile.source + { + if pid == provider_id && *tid == remote_token_id { + is_imported = true; + imported_profile_name = Some(name.clone()); + break; + } + } + } + } + _ => unreachable!("TOOLS 数组只包含已知工具 ID"), + } + + results.push(super::types::TokenImportStatus { + tool_id: tool_id.to_string(), + is_imported, + imported_profile_name, + }); + } + + Ok(results) + } + // ==================== 删除 ==================== pub fn delete_profile(&self, tool_id: &str, name: &str) -> Result<()> { diff --git a/src-tauri/src/services/profile_manager/mod.rs b/src-tauri/src/services/profile_manager/mod.rs index e608e41..f465f91 100644 --- a/src-tauri/src/services/profile_manager/mod.rs +++ b/src-tauri/src/services/profile_manager/mod.rs @@ -6,10 +6,10 @@ mod manager; mod native_config; -mod types; +pub mod types; pub use manager::ProfileManager; pub use types::{ ActiveMetadata, ActiveProfile, ActiveStore, ClaudeProfile, CodexProfile, GeminiProfile, - ProfileDescriptor, ProfilesMetadata, ProfilesStore, + ProfileDescriptor, ProfileSource, ProfilesMetadata, ProfilesStore, TokenImportStatus, }; diff --git a/src-tauri/src/services/profile_manager/types.rs b/src-tauri/src/services/profile_manager/types.rs index 7d413eb..31389f7 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, @@ -325,3 +361,16 @@ fn mask_api_key(key: &str) -> String { let suffix = &key[key.len() - 4..]; format!("{}...{}", prefix, suffix) } + +// ==================== 令牌导入状态 ==================== + +/// 令牌导入状态(用于检测令牌是否已导入到某个工具) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenImportStatus { + /// 工具 ID (claude-code, codex, gemini-cli) + pub tool_id: String, + /// 是否已导入 + pub is_imported: bool, + /// 已导入的 Profile 名称(如果已导入) + pub imported_profile_name: Option, +} diff --git a/src-tauri/src/services/provider_manager.rs b/src-tauri/src/services/provider_manager.rs index fb12c0a..a4512f8 100644 --- a/src-tauri/src/services/provider_manager.rs +++ b/src-tauri/src/services/provider_manager.rs @@ -103,6 +103,7 @@ impl ProviderManager { provider.name = updated.name; provider.website_url = updated.website_url; + provider.api_address = updated.api_address; provider.user_id = updated.user_id; provider.access_token = updated.access_token; provider.username = updated.username; diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index d56ea0e..0f1f92d 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -50,7 +50,7 @@ const navigationItems = [ ]; const secondaryItems = [ - { id: 'provider-management', label: '供应商管理', icon: Building2 }, + { id: 'provider-management', label: '供应商', icon: Building2 }, { id: 'help', label: '帮助', icon: HelpCircle }, { id: 'settings', label: '设置', icon: SettingsIcon }, ]; diff --git a/src/lib/tauri-commands/provider.ts b/src/lib/tauri-commands/provider.ts index f5f7748..b66d854 100644 --- a/src/lib/tauri-commands/provider.ts +++ b/src/lib/tauri-commands/provider.ts @@ -2,7 +2,7 @@ // 负责供应商的 CRUD、验证 import { invoke } from '@tauri-apps/api/core'; -import type { Provider, _ProviderFormData, ProviderValidationResult } from './types'; +import type { Provider, _ProviderFormData, ProviderValidationResult, ApiInfo } from './types'; /** * 列出所有供应商 @@ -47,3 +47,17 @@ export async function validateProviderConfig( }; } } + +/** + * 获取供应商的 API 地址列表 + * 从 {websiteUrl}/api/status 获取 data.api_info 数组 + * 失败时返回空数组(降级处理) + */ +export async function fetchProviderApiAddresses(websiteUrl: string): Promise { + try { + return await invoke('fetch_provider_api_addresses', { websiteUrl }); + } catch (error) { + console.error('获取 API 地址列表失败:', error); + return []; + } +} diff --git a/src/lib/tauri-commands/token.ts b/src/lib/tauri-commands/token.ts new file mode 100644 index 0000000..ad265fd --- /dev/null +++ b/src/lib/tauri-commands/token.ts @@ -0,0 +1,118 @@ +// 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, + TokenImportStatus, + UpdateRemoteTokenRequest, +} 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 }); +} + +/** + * 更新供应商的远程令牌(完整版本,支持所有字段) + */ +export async function updateProviderTokenFull( + provider: Provider, + tokenId: number, + request: UpdateRemoteTokenRequest, +): Promise { + return invoke('update_provider_token_full', { provider, tokenId, request }); +} + +/** + * 导入远程令牌为本地 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, + }); +} + +/** + * 检测令牌是否已导入到任何工具 + */ +export async function checkTokenImportStatus( + providerId: string, + remoteTokenId: number, +): Promise { + return invoke('check_token_import_status', { + profileManager: null, // Managed by Tauri State + providerId, + remoteTokenId, + }); +} diff --git a/src/lib/tauri-commands/types.ts b/src/lib/tauri-commands/types.ts index 6de3b9a..d4cf3bc 100644 --- a/src/lib/tauri-commands/types.ts +++ b/src/lib/tauri-commands/types.ts @@ -8,6 +8,7 @@ import type { ProviderStore, _ProviderFormData, ProviderValidationResult, + ApiInfo, } from '@/types/provider'; // 重新导出 Profile 相关类型供其他模块使用 @@ -17,7 +18,7 @@ export type { ProfileData, ProfileDescriptor, ProfilePayload, ToolId }; export type { SSHConfig }; // 重新导出供应商管理类型 -export type { Provider, ProviderStore, _ProviderFormData, ProviderValidationResult }; +export type { Provider, ProviderStore, _ProviderFormData, ProviderValidationResult, ApiInfo }; export interface ToolStatus { mirrorIsStale: boolean; diff --git a/src/pages/DashboardPage/components/ProviderTabs.tsx b/src/pages/DashboardPage/components/ProviderTabs.tsx index 25e4024..f94beef 100644 --- a/src/pages/DashboardPage/components/ProviderTabs.tsx +++ b/src/pages/DashboardPage/components/ProviderTabs.tsx @@ -70,27 +70,29 @@ export function ProviderTabs({ {/* 供应商标签列表和刷新按钮 */}
- - {providers.map((provider) => { - const isSelected = provider.id === currentProviderId; - return ( - -
- {provider.name} - {isSelected && ( - - 当前 - - )} -
-
- ); - })} -
+
+ + {providers.map((provider) => { + const isSelected = provider.id === currentProviderId; + return ( + +
+ {provider.name} + {isSelected && ( + + 当前 + + )} +
+
+ ); + })} +
+
{/* 刷新按钮 */} {onRefresh && ( 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; + /** 当前工具 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 new file mode 100644 index 0000000..a656960 --- /dev/null +++ b/src/pages/ProfileManagementPage/components/ImportFromProviderDialog.tsx @@ -0,0 +1,913 @@ +/** + * 从供应商导入 Profile 对话框(完全重写版本) + * + * 支持两种导入方式: + * - Tab A:选择现有令牌并导入 + * - Tab B:创建新令牌并直接导入 + */ + +import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +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 { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2, Download, AlertCircle } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import type { ToolId } from '@/types/profile'; +import type { Provider } from '@/types/provider'; +import type { + RemoteToken, + RemoteTokenGroup, + CreateRemoteTokenRequest, + TokenImportStatus, +} from '@/types/remote-token'; +import { listProviders } from '@/lib/tauri-commands/provider'; +import { + fetchProviderTokens, + fetchProviderGroups, + importTokenAsProfile, + createProviderToken, + checkTokenImportStatus, +} 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 const ImportFromProviderDialog = forwardRef< + ImportFromProviderDialogRef, + ImportFromProviderDialogProps +>(({ open, onOpenChange, toolId, onSuccess, autoTriggerGenerate }, ref) => { + const { toast } = useToast(); + + // ==================== 数据状态 ==================== + const [providers, setProviders] = useState([]); + const [tokens, setTokens] = useState([]); + const [tokenGroups, setTokenGroups] = useState([]); + + // ==================== 选择状态 ==================== + 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 [tokenImportStatus, setTokenImportStatus] = useState([]); + const [checkingImportStatus, setCheckingImportStatus] = useState(false); + + // 获取当前选中的供应商和令牌 + const selectedProvider = providers.find((p) => p.id === providerId); + const selectedToken = tokens.find((t) => t.id === tokenId); + + /** + * 检查令牌是否已导入到当前工具 + */ + const isTokenAlreadyImported = (): boolean => { + const currentToolStatus = tokenImportStatus.find((s) => s.tool_id === toolId); + return currentToolStatus?.is_imported ?? false; + }; + + /** + * 加载供应商列表 + */ + const loadProviders = async () => { + try { + 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({ + title: '加载供应商失败', + description: errorMsg, + variant: 'destructive', + }); + } finally { + setLoadingProviders(false); + } + }; + + /** + * 加载令牌列表 + */ + const loadTokens = async (provider: Provider) => { + try { + setLoadingTokens(true); + const result = await fetchProviderTokens(provider); + // 自动为没有 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({ + title: '加载令牌失败', + description: errorMsg, + variant: 'destructive', + }); + setTokens([]); + } finally { + setLoadingTokens(false); + } + }; + + /** + * 加载分组列表(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) { + // 重置所有状态 + setProviderId(''); + setTokenId(null); + setProfileName(''); + setTokens([]); + setTokenGroups([]); + setActiveTab('select'); + setNewTokenName(''); + setGroupId(''); + setQuota(-1); + setExpireDays(0); + setUnlimitedQuota(true); + setUnlimitedExpire(true); + setTokenImportStatus([]); + setCheckingImportStatus(false); + + // 加载供应商列表 + loadProviders(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + /** + * 供应商变更时加载令牌和分组 + */ + useEffect(() => { + if (selectedProvider) { + loadTokens(selectedProvider); + loadGroups(selectedProvider); + setTokenId(null); + } else { + setTokens([]); + setTokenGroups([]); + setTokenId(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [providerId]); + + /** + * 检测令牌是否已导入 + */ + const checkImportStatus = async (provider: Provider, token: RemoteToken) => { + try { + setCheckingImportStatus(true); + const status = await checkTokenImportStatus(provider.id, token.id); + setTokenImportStatus(status); + } catch (err) { + console.error('检测令牌导入状态失败:', err); + setTokenImportStatus([]); + } finally { + setCheckingImportStatus(false); + } + }; + + /** + * 令牌变更时自动填充 Profile 名称并检测导入状态 + */ + useEffect(() => { + if (selectedToken && !profileName) { + setProfileName(selectedToken.name + '_profile'); + } + // 检测令牌是否已导入 + if (selectedToken && selectedProvider) { + checkImportStatus(selectedProvider, selectedToken); + } else { + setTokenImportStatus([]); + } + // 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 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: '请选择供应商和令牌', + 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); + } + }; + + /** + * 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 配置 + + + +
+ {/* 供应商选择器 */} +
+ + +

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

+
+ + {/* DuckCoding 分组说明(独立于 Tabs) */} + {selectedProvider?.id === 'duckcoding' && ( + + )} + + {/* Tabs 切换 */} + setActiveTab(v as 'select' | 'create')}> + + 选择令牌 + 创建令牌 + + + {/* Tab A: 选择令牌 */} + + {/* 令牌选择器 */} +
+ + +

选择要导入的令牌

+
+ + {/* 令牌详情卡片 */} + {selectedToken && ( + g.id === selectedToken.group)} + /> + )} + + {/* 令牌导入状态提示 */} + {selectedToken && + tokenImportStatus.length > 0 && + (() => { + const currentToolStatus = tokenImportStatus.find((s) => s.tool_id === toolId); + if (currentToolStatus?.is_imported) { + return ( + + + + 此令牌已在{' '} + {toolId === 'claude-code' + ? 'Claude Code' + : toolId === 'codex' + ? 'Codex' + : 'Gemini CLI'}{' '} + 中添加 + {currentToolStatus.imported_profile_name && + `(Profile: ${currentToolStatus.imported_profile_name})`} + + + ); + } + return null; + })()} + + {/* Profile 名称输入 */} + + + {/* 导入按钮 */} + + + + +
+ + {/* Tab B: 创建令牌 */} + + {/* 令牌名称 */} +
+ + setNewTokenName(e.target.value)} + placeholder="例如: my_api_key" + disabled={!selectedProvider} + /> +

为新令牌设置一个名称

+
+ + {/* 分组选择器 */} +
+ + +

选择令牌所属分组

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

设置令牌的使用限额

+
+ + {/* 有效期设置 */} +
+
+ +
+ setUnlimitedExpire(checked === true)} + /> + +
+
+ setExpireDays(Number(e.target.value))} + placeholder="例如: 365" + disabled={unlimitedExpire} + /> +

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

+
+ + {/* Profile 名称输入 */} + + + {/* 创建并导入按钮 */} + + + + +
+
+
+
+
+ ); +}); + +ImportFromProviderDialog.displayName = 'ImportFromProviderDialog'; 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/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 84a9bd9..6fc7e8c 100644 --- a/src/pages/ProfileManagementPage/index.tsx +++ b/src/pages/ProfileManagementPage/index.tsx @@ -2,10 +2,16 @@ * Profile 配置管理页面 */ -import { useState, useEffect } from 'react'; -import { RefreshCw, Loader2, HelpCircle } from 'lucide-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'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, @@ -17,6 +23,8 @@ 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 { CreateCustomProfileDialog } from './components/CreateCustomProfileDialog'; import { useProfileManagement } from './hooks/useProfileManagement'; import type { ToolId, ProfileFormData, ProfileDescriptor } from '@/types/profile'; import { logoMap } from '@/utils/constants'; @@ -40,19 +48,18 @@ 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); + 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'); @@ -167,13 +174,24 @@ export default function ProfileManagementPage() { {group.active_profile && ` · 当前激活: ${group.active_profile.name}`}

- + + + + + + setCustomProfileDialogOpen(true)}> + + 手动创建 + + setImportDialogOpen(true)}> + + 从供应商导入 + + + {/* Profile 卡片列表 */} @@ -213,6 +231,41 @@ 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/components/EditTokenDialog.tsx b/src/pages/ProviderManagementPage/components/EditTokenDialog.tsx new file mode 100644 index 0000000..b58d85a --- /dev/null +++ b/src/pages/ProviderManagementPage/components/EditTokenDialog.tsx @@ -0,0 +1,431 @@ +/** + * 编辑远程令牌对话框 + * 支持修改令牌的所有可配置字段,包括 CIDR 表达式验证的 IP 白名单 + */ + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2, AlertTriangle } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import type { Provider } from '@/types/provider'; +import type { RemoteToken, RemoteTokenGroup, UpdateRemoteTokenRequest } from '@/types/remote-token'; +import { validateIpWhitelist, formatIpWhitelist } from '../utils/ipValidation'; + +interface EditTokenDialogProps { + /** 对话框打开状态 */ + open: boolean; + /** 对话框状态变更回调 */ + onOpenChange: (open: boolean) => void; + /** 当前供应商 */ + provider: Provider; + /** 要编辑的令牌 */ + token: RemoteToken | null; + /** 令牌分组列表 */ + tokenGroups: RemoteTokenGroup[]; + /** 更新成功回调 */ + onSuccess: () => void; + /** 更新函数 */ + onUpdate: ( + provider: Provider, + tokenId: number, + request: UpdateRemoteTokenRequest, + ) => Promise; +} + +/** + * 编辑令牌对话框 + */ +export function EditTokenDialog({ + open, + onOpenChange, + provider, + token, + tokenGroups, + onSuccess, + onUpdate, +}: EditTokenDialogProps) { + const { toast } = useToast(); + + // ==================== 表单状态 ==================== + const [name, setName] = useState(''); + const [groupId, setGroupId] = useState(''); + const [quota, setQuota] = useState(0); + const [unlimitedQuota, setUnlimitedQuota] = useState(false); + const [expiredTime, setExpiredTime] = useState(-1); + const [unlimitedExpire, setUnlimitedExpire] = useState(true); + const [modelLimitsEnabled, setModelLimitsEnabled] = useState(false); + const [modelLimits, setModelLimits] = useState(''); + const [allowIps, setAllowIps] = useState(''); + + // ==================== 验证状态 ==================== + const [ipValidationResult, setIpValidationResult] = useState | null>(null); + + // ==================== 加载状态 ==================== + const [updating, setUpdating] = useState(false); + + /** + * Dialog 打开时初始化表单 + */ + useEffect(() => { + if (open && token) { + setName(token.name); + setGroupId(token.group); + setUnlimitedQuota(token.unlimited_quota); + setQuota(token.remain_quota / 500000); // token -> USD + setUnlimitedExpire(token.expired_time === -1); + setExpiredTime(token.expired_time); + setModelLimitsEnabled(token.model_limits_enabled); + setModelLimits(token.model_limits); + setAllowIps(token.allow_ips); + setIpValidationResult(null); + } + }, [open, token]); + + /** + * IP 白名单实时验证 + */ + useEffect(() => { + if (allowIps) { + const result = validateIpWhitelist(allowIps); + setIpValidationResult(result); + } else { + setIpValidationResult(null); + } + }, [allowIps]); + + /** + * 计算过期时间显示值(天数) + */ + const getExpireDays = (): number => { + if (unlimitedExpire || expiredTime === -1) return 0; + const now = Math.floor(Date.now() / 1000); + const remainingSeconds = expiredTime - now; + return Math.max(0, Math.ceil(remainingSeconds / (24 * 60 * 60))); + }; + + /** + * 设置过期天数 + */ + const setExpireDays = (days: number) => { + const now = Math.floor(Date.now() / 1000); + setExpiredTime(now + days * 24 * 60 * 60); + }; + + /** + * 提交更新 + */ + const handleSubmit = async () => { + if (!token) return; + + // 验证必填项 + if (!name.trim()) { + toast({ + title: '请输入令牌名称', + variant: 'destructive', + }); + return; + } + + if (!groupId) { + toast({ + title: '请选择分组', + variant: 'destructive', + }); + return; + } + + // 验证 IP 白名单 + if (allowIps.trim()) { + const result = validateIpWhitelist(allowIps); + if (!result.valid) { + toast({ + title: 'IP 白名单验证失败', + description: result.errors[0] || '请检查格式', + variant: 'destructive', + }); + return; + } + + // 警告但允许继续 + if (result.warnings.length > 0) { + // 用户需要确认警告 + const confirmed = window.confirm( + `检测到以下安全警告:\n\n${result.warnings.join('\n')}\n\n是否继续保存?`, + ); + if (!confirmed) return; + } + } + + setUpdating(true); + try { + // 构建更新请求 + const request: UpdateRemoteTokenRequest = { + name: name.trim(), + group: groupId, + remain_quota: unlimitedQuota ? 500000 : quota * 500000, // USD -> token + unlimited_quota: unlimitedQuota, + expired_time: unlimitedExpire ? -1 : expiredTime, + model_limits_enabled: modelLimitsEnabled, + model_limits: modelLimitsEnabled ? modelLimits : '', + allow_ips: formatIpWhitelist(allowIps), // 格式化后保存 + }; + + await onUpdate(provider, token.id, request); + + toast({ + title: '更新成功', + description: `令牌「${name}」已成功更新`, + }); + + onSuccess(); + onOpenChange(false); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + toast({ + title: '更新失败', + description: errorMsg, + variant: 'destructive', + }); + } finally { + setUpdating(false); + } + }; + + if (!token) return null; + + return ( + + + + 编辑令牌 + 修改令牌的配置信息,支持 CIDR 表达式的 IP 白名单 + + +
+ {/* 令牌名称 */} +
+ + setName(e.target.value)} + placeholder="例如: my_api_key" + /> +

为令牌设置一个便于识别的名称

+
+ + {/* 分组选择器 */} +
+ + +

选择令牌所属分组

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

设置令牌的剩余使用限额

+
+ + {/* 有效期设置 */} +
+
+ +
+ setUnlimitedExpire(checked === true)} + /> + +
+
+ setExpireDays(Number(e.target.value))} + placeholder="例如: 365" + disabled={unlimitedExpire} + /> +

设置令牌的剩余有效期

+
+ + {/* 模型限制 */} +
+
+ +
+ setModelLimitsEnabled(checked === true)} + /> + +
+
+ setModelLimits(e.target.value)} + placeholder="例如: gpt-4,gpt-3.5-turbo" + disabled={!modelLimitsEnabled} + /> +

使用逗号分隔多个模型名称

+
+ + {/* IP 白名单 */} +
+ +