diff --git a/.gitignore b/.gitignore index 6626d7d..8859ed0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ CLAUDE.md AGENTS.md /.claude /.vscode +vitest-report.json diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 7e1f244..c0daeea 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -73,8 +73,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result, + #[allow(non_snake_case)] defaultPath: Option, ) -> Result, String> { let initial = defaultPath .map(|p| p.trim().to_string()) diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index 7fb8ad3..245ff4c 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -12,8 +12,7 @@ use crate::store::AppState; /// 导出配置文件 #[tauri::command] pub async fn export_config_to_file( - #[allow(non_snake_case)] - filePath: String + #[allow(non_snake_case)] filePath: String, ) -> Result { tauri::async_runtime::spawn_blocking(move || { let target_path = PathBuf::from(&filePath); @@ -32,8 +31,7 @@ pub async fn export_config_to_file( /// 从文件导入配置 #[tauri::command] pub async fn import_config_from_file( - #[allow(non_snake_case)] - filePath: String, + #[allow(non_snake_case)] filePath: String, state: State<'_, AppState>, ) -> Result { let (new_config, backup_id) = tauri::async_runtime::spawn_blocking(move || { @@ -81,8 +79,7 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result( app: tauri::AppHandle, - #[allow(non_snake_case)] - defaultName: String, + #[allow(non_snake_case)] defaultName: String, ) -> Result, String> { let dialog = app.dialog(); let result = dialog diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index ff1f9d7..95ed580 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1,8 +1,8 @@ #![allow(non_snake_case)] +use crate::init_status::InitErrorPayload; use tauri::AppHandle; use tauri_plugin_opener::OpenerExt; -use crate::init_status::InitErrorPayload; /// 打开外部链接 #[tauri::command] diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index a55814a..2fa6893 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -112,8 +112,7 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result< #[tauri::command] pub async fn queryProviderUsage( state: State<'_, AppState>, - #[allow(non_snake_case)] - providerId: String, // 使用 camelCase 匹配前端 + #[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端 app: String, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; @@ -127,16 +126,12 @@ pub async fn queryProviderUsage( #[tauri::command] pub async fn testUsageScript( state: State<'_, AppState>, - #[allow(non_snake_case)] - providerId: String, + #[allow(non_snake_case)] providerId: String, app: String, - #[allow(non_snake_case)] - scriptCode: String, + #[allow(non_snake_case)] scriptCode: String, timeout: Option, - #[allow(non_snake_case)] - accessToken: Option, - #[allow(non_snake_case)] - userId: Option, + #[allow(non_snake_case)] accessToken: Option, + #[allow(non_snake_case)] userId: Option, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::test_usage_script( @@ -163,8 +158,7 @@ pub fn read_live_provider_settings(app: String) -> Result, - #[allow(non_snake_case)] - timeoutSecs: Option, + #[allow(non_snake_case)] timeoutSecs: Option, ) -> Result, String> { SpeedtestService::test_endpoints(urls, timeoutSecs) .await @@ -176,8 +170,7 @@ pub async fn test_api_endpoints( pub fn get_custom_endpoints( state: State<'_, AppState>, app: String, - #[allow(non_snake_case)] - providerId: String, + #[allow(non_snake_case)] providerId: String, ) -> Result, String> { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::get_custom_endpoints(state.inner(), app_type, &providerId) @@ -189,8 +182,7 @@ pub fn get_custom_endpoints( pub fn add_custom_endpoint( state: State<'_, AppState>, app: String, - #[allow(non_snake_case)] - providerId: String, + #[allow(non_snake_case)] providerId: String, url: String, ) -> Result<(), String> { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; @@ -203,8 +195,7 @@ pub fn add_custom_endpoint( pub fn remove_custom_endpoint( state: State<'_, AppState>, app: String, - #[allow(non_snake_case)] - providerId: String, + #[allow(non_snake_case)] providerId: String, url: String, ) -> Result<(), String> { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; @@ -217,8 +208,7 @@ pub fn remove_custom_endpoint( pub fn update_endpoint_last_used( state: State<'_, AppState>, app: String, - #[allow(non_snake_case)] - providerId: String, + #[allow(non_snake_case)] providerId: String, url: String, ) -> Result<(), String> { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/init_status.rs b/src-tauri/src/init_status.rs index 95ccb62..a346337 100644 --- a/src-tauri/src/init_status.rs +++ b/src-tauri/src/init_status.rs @@ -39,4 +39,3 @@ mod tests { assert_eq!(got.error, payload.error); } } - diff --git a/src-tauri/src/services/provider.rs b/src-tauri/src/services/provider.rs index 5aa8b82..dccfb84 100644 --- a/src-tauri/src/services/provider.rs +++ b/src-tauri/src/services/provider.rs @@ -142,19 +142,31 @@ impl ProviderService { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let target_haiku = current_haiku.or_else(|| small_fast.clone()).or_else(|| model.clone()); - let target_sonnet = current_sonnet.or_else(|| model.clone()).or_else(|| small_fast.clone()); - let target_opus = current_opus.or_else(|| model.clone()).or_else(|| small_fast.clone()); + let target_haiku = current_haiku + .or_else(|| small_fast.clone()) + .or_else(|| model.clone()); + let target_sonnet = current_sonnet + .or_else(|| model.clone()) + .or_else(|| small_fast.clone()); + let target_opus = current_opus + .or_else(|| model.clone()) + .or_else(|| small_fast.clone()); if env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL").is_none() { if let Some(v) = target_haiku { - env.insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), Value::String(v)); + env.insert( + "ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), + Value::String(v), + ); changed = true; } } if env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").is_none() { if let Some(v) = target_sonnet { - env.insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), Value::String(v)); + env.insert( + "ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), + Value::String(v), + ); changed = true; } } @@ -619,16 +631,13 @@ impl ProviderService { let manager = cfg .get_manager_mut(&app_type) .ok_or_else(|| Self::app_not_found(&app_type))?; - let provider = manager - .providers - .get_mut(provider_id) - .ok_or_else(|| { - AppError::localized( - "provider.not_found", - format!("供应商不存在: {}", provider_id), - format!("Provider not found: {}", provider_id), - ) - })?; + let provider = manager.providers.get_mut(provider_id).ok_or_else(|| { + AppError::localized( + "provider.not_found", + format!("供应商不存在: {}", provider_id), + format!("Provider not found: {}", provider_id), + ) + })?; let meta = provider.meta.get_or_insert_with(ProviderMeta::default); let endpoint = CustomEndpoint { @@ -737,19 +746,21 @@ impl ProviderService { { Ok(data) => { let usage_list: Vec = if data.is_array() { - serde_json::from_value(data) - .map_err(|e| AppError::localized( + serde_json::from_value(data).map_err(|e| { + AppError::localized( "usage_script.data_format_error", format!("数据格式错误: {}", e), - format!("Data format error: {}", e) - ))? + format!("Data format error: {}", e), + ) + })? } else { - let single: UsageData = serde_json::from_value(data) - .map_err(|e| AppError::localized( + let single: UsageData = serde_json::from_value(data).map_err(|e| { + AppError::localized( "usage_script.data_format_error", format!("数据格式错误: {}", e), - format!("Data format error: {}", e) - ))?; + format!("Data format error: {}", e), + ) + })?; vec![single] }; @@ -766,7 +777,11 @@ impl ProviderService { let msg = match err { AppError::Localized { zh, en, .. } => { - if lang == "en" { en } else { zh } + if lang == "en" { + en + } else { + zh + } } other => other.to_string(), }; @@ -791,17 +806,13 @@ impl ProviderService { let manager = config .get_manager(&app_type) .ok_or_else(|| Self::app_not_found(&app_type))?; - let provider = manager - .providers - .get(provider_id) - .cloned() - .ok_or_else(|| { - AppError::localized( - "provider.not_found", - format!("供应商不存在: {}", provider_id), - format!("Provider not found: {}", provider_id), - ) - })?; + let provider = manager.providers.get(provider_id).cloned().ok_or_else(|| { + AppError::localized( + "provider.not_found", + format!("供应商不存在: {}", provider_id), + format!("Provider not found: {}", provider_id), + ) + })?; let (script_code, timeout, access_token, user_id) = { let usage_script = provider .meta @@ -861,17 +872,13 @@ impl ProviderService { let manager = config .get_manager(&app_type) .ok_or_else(|| Self::app_not_found(&app_type))?; - manager - .providers - .get(provider_id) - .cloned() - .ok_or_else(|| { - AppError::localized( - "provider.not_found", - format!("供应商不存在: {}", provider_id), - format!("Provider not found: {}", provider_id), - ) - })? + manager.providers.get(provider_id).cloned().ok_or_else(|| { + AppError::localized( + "provider.not_found", + format!("供应商不存在: {}", provider_id), + format!("Provider not found: {}", provider_id), + ) + })? }; let (api_key, base_url) = Self::extract_credentials(&provider, &app_type)?; @@ -1206,12 +1213,13 @@ impl ProviderService { .unwrap_or(""); let base_url = if config_toml.contains("base_url") { - let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#) - .map_err(|e| AppError::localized( + let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| { + AppError::localized( "provider.regex_init_failed", format!("正则初始化失败: {}", e), - format!("Failed to initialize regex: {}", e) - ))?; + format!("Failed to initialize regex: {}", e), + ) + })?; re.captures(config_toml) .and_then(|caps| caps.get(1)) .map(|m| m.as_str().to_string()) @@ -1239,7 +1247,7 @@ impl ProviderService { AppError::localized( "provider.app_not_found", format!("应用类型不存在: {:?}", app_type), - format!("App type not found: {:?}", app_type) + format!("App type not found: {:?}", app_type), ) } @@ -1265,17 +1273,13 @@ impl ProviderService { )); } - manager - .providers - .get(provider_id) - .cloned() - .ok_or_else(|| { - AppError::localized( - "provider.not_found", - format!("供应商不存在: {}", provider_id), - format!("Provider not found: {}", provider_id), - ) - })? + manager.providers.get(provider_id).cloned().ok_or_else(|| { + AppError::localized( + "provider.not_found", + format!("供应商不存在: {}", provider_id), + format!("Provider not found: {}", provider_id), + ) + })? }; match app_type { diff --git a/src-tauri/src/services/speedtest.rs b/src-tauri/src/services/speedtest.rs index cc0896f..9eda578 100644 --- a/src-tauri/src/services/speedtest.rs +++ b/src-tauri/src/services/speedtest.rs @@ -101,11 +101,13 @@ impl SpeedtestService { .redirect(reqwest::redirect::Policy::limited(5)) .user_agent("cc-switch-speedtest/1.0") .build() - .map_err(|e| AppError::localized( - "speedtest.client_create_failed", - format!("创建 HTTP 客户端失败: {e}"), - format!("Failed to create HTTP client: {e}") - )) + .map_err(|e| { + AppError::localized( + "speedtest.client_create_failed", + format!("创建 HTTP 客户端失败: {e}"), + format!("Failed to create HTTP client: {e}"), + ) + }) } fn sanitize_timeout(timeout_secs: Option) -> u64 { diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index 2065e76..8ec7357 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -30,80 +30,171 @@ pub async fn execute_usage_script( // 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放) let request_config = { - let runtime = - Runtime::new().map_err(|e| AppError::localized("usage_script.runtime_create_failed", format!("创建 JS 运行时失败: {}", e), format!("Failed to create JS runtime: {}", e)))?; - let context = Context::full(&runtime) - .map_err(|e| AppError::localized("usage_script.context_create_failed", format!("创建 JS 上下文失败: {}", e), format!("Failed to create JS context: {}", e)))?; + let runtime = Runtime::new().map_err(|e| { + AppError::localized( + "usage_script.runtime_create_failed", + format!("创建 JS 运行时失败: {}", e), + format!("Failed to create JS runtime: {}", e), + ) + })?; + let context = Context::full(&runtime).map_err(|e| { + AppError::localized( + "usage_script.context_create_failed", + format!("创建 JS 上下文失败: {}", e), + format!("Failed to create JS context: {}", e), + ) + })?; context.with(|ctx| { // 执行用户代码,获取配置对象 - let config: rquickjs::Object = ctx - .eval(replaced.clone()) - .map_err(|e| AppError::localized("usage_script.config_parse_failed", format!("解析配置失败: {}", e), format!("Failed to parse config: {}", e)))?; + let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| { + AppError::localized( + "usage_script.config_parse_failed", + format!("解析配置失败: {}", e), + format!("Failed to parse config: {}", e), + ) + })?; // 提取 request 配置 - let request: rquickjs::Object = config - .get("request") - .map_err(|e| AppError::localized("usage_script.request_missing", format!("缺少 request 配置: {}", e), format!("Missing request config: {}", e)))?; + let request: rquickjs::Object = config.get("request").map_err(|e| { + AppError::localized( + "usage_script.request_missing", + format!("缺少 request 配置: {}", e), + format!("Missing request config: {}", e), + ) + })?; // 将 request 转换为 JSON 字符串 let request_json: String = ctx .json_stringify(request) - .map_err(|e| AppError::localized("usage_script.request_serialize_failed", format!("序列化 request 失败: {}", e), format!("Failed to serialize request: {}", e)))? - .ok_or_else(|| AppError::localized("usage_script.serialize_none", "序列化返回 None", "Serialization returned None"))? + .map_err(|e| { + AppError::localized( + "usage_script.request_serialize_failed", + format!("序列化 request 失败: {}", e), + format!("Failed to serialize request: {}", e), + ) + })? + .ok_or_else(|| { + AppError::localized( + "usage_script.serialize_none", + "序列化返回 None", + "Serialization returned None", + ) + })? .get() - .map_err(|e| AppError::localized("usage_script.get_string_failed", format!("获取字符串失败: {}", e), format!("Failed to get string: {}", e)))?; + .map_err(|e| { + AppError::localized( + "usage_script.get_string_failed", + format!("获取字符串失败: {}", e), + format!("Failed to get string: {}", e), + ) + })?; Ok::<_, AppError>(request_json) })? }; // Runtime 和 Context 在这里被 drop // 3. 解析 request 配置 - let request: RequestConfig = serde_json::from_str(&request_config) - .map_err(|e| AppError::localized("usage_script.request_format_invalid", format!("request 配置格式错误: {}", e), format!("Invalid request config format: {}", e)))?; + let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| { + AppError::localized( + "usage_script.request_format_invalid", + format!("request 配置格式错误: {}", e), + format!("Invalid request config format: {}", e), + ) + })?; // 4. 发送 HTTP 请求 let response_data = send_http_request(&request, timeout_secs).await?; // 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放) let result: Value = { - let runtime = - Runtime::new().map_err(|e| AppError::localized("usage_script.runtime_create_failed", format!("创建 JS 运行时失败: {}", e), format!("Failed to create JS runtime: {}", e)))?; - let context = Context::full(&runtime) - .map_err(|e| AppError::localized("usage_script.context_create_failed", format!("创建 JS 上下文失败: {}", e), format!("Failed to create JS context: {}", e)))?; + let runtime = Runtime::new().map_err(|e| { + AppError::localized( + "usage_script.runtime_create_failed", + format!("创建 JS 运行时失败: {}", e), + format!("Failed to create JS runtime: {}", e), + ) + })?; + let context = Context::full(&runtime).map_err(|e| { + AppError::localized( + "usage_script.context_create_failed", + format!("创建 JS 上下文失败: {}", e), + format!("Failed to create JS context: {}", e), + ) + })?; context.with(|ctx| { // 重新 eval 获取配置对象 - let config: rquickjs::Object = ctx - .eval(replaced.clone()) - .map_err(|e| AppError::localized("usage_script.config_reparse_failed", format!("重新解析配置失败: {}", e), format!("Failed to re-parse config: {}", e)))?; + let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| { + AppError::localized( + "usage_script.config_reparse_failed", + format!("重新解析配置失败: {}", e), + format!("Failed to re-parse config: {}", e), + ) + })?; // 提取 extractor 函数 - let extractor: Function = config - .get("extractor") - .map_err(|e| AppError::localized("usage_script.extractor_missing", format!("缺少 extractor 函数: {}", e), format!("Missing extractor function: {}", e)))?; + let extractor: Function = config.get("extractor").map_err(|e| { + AppError::localized( + "usage_script.extractor_missing", + format!("缺少 extractor 函数: {}", e), + format!("Missing extractor function: {}", e), + ) + })?; // 将响应数据转换为 JS 值 - let response_js: rquickjs::Value = ctx - .json_parse(response_data.as_str()) - .map_err(|e| AppError::localized("usage_script.response_parse_failed", format!("解析响应 JSON 失败: {}", e), format!("Failed to parse response JSON: {}", e)))?; + let response_js: rquickjs::Value = + ctx.json_parse(response_data.as_str()).map_err(|e| { + AppError::localized( + "usage_script.response_parse_failed", + format!("解析响应 JSON 失败: {}", e), + format!("Failed to parse response JSON: {}", e), + ) + })?; // 调用 extractor(response) - let result_js: rquickjs::Value = extractor - .call((response_js,)) - .map_err(|e| AppError::localized("usage_script.extractor_exec_failed", format!("执行 extractor 失败: {}", e), format!("Failed to execute extractor: {}", e)))?; + let result_js: rquickjs::Value = extractor.call((response_js,)).map_err(|e| { + AppError::localized( + "usage_script.extractor_exec_failed", + format!("执行 extractor 失败: {}", e), + format!("Failed to execute extractor: {}", e), + ) + })?; // 转换为 JSON 字符串 let result_json: String = ctx .json_stringify(result_js) - .map_err(|e| AppError::localized("usage_script.result_serialize_failed", format!("序列化结果失败: {}", e), format!("Failed to serialize result: {}", e)))? - .ok_or_else(|| AppError::localized("usage_script.serialize_none", "序列化返回 None", "Serialization returned None"))? + .map_err(|e| { + AppError::localized( + "usage_script.result_serialize_failed", + format!("序列化结果失败: {}", e), + format!("Failed to serialize result: {}", e), + ) + })? + .ok_or_else(|| { + AppError::localized( + "usage_script.serialize_none", + "序列化返回 None", + "Serialization returned None", + ) + })? .get() - .map_err(|e| AppError::localized("usage_script.get_string_failed", format!("获取字符串失败: {}", e), format!("Failed to get string: {}", e)))?; + .map_err(|e| { + AppError::localized( + "usage_script.get_string_failed", + format!("获取字符串失败: {}", e), + format!("Failed to get string: {}", e), + ) + })?; // 解析为 serde_json::Value - serde_json::from_str(&result_json) - .map_err(|e| AppError::localized("usage_script.json_parse_failed", format!("JSON 解析失败: {}", e), format!("JSON parse failed: {}", e))) + serde_json::from_str(&result_json).map_err(|e| { + AppError::localized( + "usage_script.json_parse_failed", + format!("JSON 解析失败: {}", e), + format!("JSON parse failed: {}", e), + ) + }) })? }; // Runtime 和 Context 在这里被 drop @@ -131,20 +222,23 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< let client = Client::builder() .timeout(Duration::from_secs(timeout)) .build() - .map_err(|e| AppError::localized("usage_script.client_create_failed", format!("创建客户端失败: {}", e), format!("Failed to create client: {}", e)))?; - - // 严格校验 HTTP 方法,非法值不回退为 GET - let method: reqwest::Method = config - .method - .parse() - .map_err(|_| { + .map_err(|e| { AppError::localized( - "usage_script.invalid_http_method", - format!("不支持的 HTTP 方法: {}", config.method), - format!("Unsupported HTTP method: {}", config.method), + "usage_script.client_create_failed", + format!("创建客户端失败: {}", e), + format!("Failed to create client: {}", e), ) })?; + // 严格校验 HTTP 方法,非法值不回退为 GET + let method: reqwest::Method = config.method.parse().map_err(|_| { + AppError::localized( + "usage_script.invalid_http_method", + format!("不支持的 HTTP 方法: {}", config.method), + format!("Unsupported HTTP method: {}", config.method), + ) + })?; + let mut req = client.request(method.clone(), &config.url); // 添加请求头 @@ -158,16 +252,22 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< } // 发送请求 - let resp = req - .send() - .await - .map_err(|e| AppError::localized("usage_script.request_failed", format!("请求失败: {}", e), format!("Request failed: {}", e)))?; + let resp = req.send().await.map_err(|e| { + AppError::localized( + "usage_script.request_failed", + format!("请求失败: {}", e), + format!("Request failed: {}", e), + ) + })?; let status = resp.status(); - let text = resp - .text() - .await - .map_err(|e| AppError::localized("usage_script.read_response_failed", format!("读取响应失败: {}", e), format!("Failed to read response: {}", e)))?; + let text = resp.text().await.map_err(|e| { + AppError::localized( + "usage_script.read_response_failed", + format!("读取响应失败: {}", e), + format!("Failed to read response: {}", e), + ) + })?; if !status.is_success() { let preview = if text.len() > 200 { @@ -175,7 +275,11 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< } else { text.clone() }; - return Err(AppError::localized("usage_script.http_error", format!("HTTP {} : {}", status, preview), format!("HTTP {} : {}", status, preview))); + return Err(AppError::localized( + "usage_script.http_error", + format!("HTTP {} : {}", status, preview), + format!("HTTP {} : {}", status, preview), + )); } Ok(text) @@ -186,11 +290,20 @@ fn validate_result(result: &Value) -> Result<(), AppError> { // 如果是数组,验证每个元素 if let Some(arr) = result.as_array() { if arr.is_empty() { - return Err(AppError::localized("usage_script.empty_array", "脚本返回的数组不能为空", "Script returned empty array")); + return Err(AppError::localized( + "usage_script.empty_array", + "脚本返回的数组不能为空", + "Script returned empty array", + )); } for (idx, item) in arr.iter().enumerate() { - validate_single_usage(item) - .map_err(|e| AppError::localized("usage_script.array_validation_failed", format!("数组索引[{}]验证失败: {}", idx, e), format!("Validation failed at index [{}]: {}", idx, e)))?; + validate_single_usage(item).map_err(|e| { + AppError::localized( + "usage_script.array_validation_failed", + format!("数组索引[{}]验证失败: {}", idx, e), + format!("Validation failed at index [{}]: {}", idx, e), + ) + })?; } return Ok(()); } @@ -201,46 +314,82 @@ fn validate_result(result: &Value) -> Result<(), AppError> { /// 验证单个用量数据对象 fn validate_single_usage(result: &Value) -> Result<(), AppError> { - let obj = result - .as_object() - .ok_or_else(|| AppError::localized("usage_script.must_return_object", "脚本必须返回对象或对象数组", "Script must return object or array of objects"))?; + let obj = result.as_object().ok_or_else(|| { + AppError::localized( + "usage_script.must_return_object", + "脚本必须返回对象或对象数组", + "Script must return object or array of objects", + ) + })?; // 所有字段均为可选,只进行类型检查 if obj.contains_key("isValid") && !result["isValid"].is_null() && !result["isValid"].is_boolean() { - return Err(AppError::localized("usage_script.isvalid_type_error", "isValid 必须是布尔值或 null", "isValid must be boolean or null")); + return Err(AppError::localized( + "usage_script.isvalid_type_error", + "isValid 必须是布尔值或 null", + "isValid must be boolean or null", + )); } if obj.contains_key("invalidMessage") && !result["invalidMessage"].is_null() && !result["invalidMessage"].is_string() { - return Err(AppError::localized("usage_script.invalidmessage_type_error", "invalidMessage 必须是字符串或 null", "invalidMessage must be string or null")); + return Err(AppError::localized( + "usage_script.invalidmessage_type_error", + "invalidMessage 必须是字符串或 null", + "invalidMessage must be string or null", + )); } if obj.contains_key("remaining") && !result["remaining"].is_null() && !result["remaining"].is_number() { - return Err(AppError::localized("usage_script.remaining_type_error", "remaining 必须是数字或 null", "remaining must be number or null")); + return Err(AppError::localized( + "usage_script.remaining_type_error", + "remaining 必须是数字或 null", + "remaining must be number or null", + )); } if obj.contains_key("unit") && !result["unit"].is_null() && !result["unit"].is_string() { - return Err(AppError::localized("usage_script.unit_type_error", "unit 必须是字符串或 null", "unit must be string or null")); + return Err(AppError::localized( + "usage_script.unit_type_error", + "unit 必须是字符串或 null", + "unit must be string or null", + )); } if obj.contains_key("total") && !result["total"].is_null() && !result["total"].is_number() { - return Err(AppError::localized("usage_script.total_type_error", "total 必须是数字或 null", "total must be number or null")); + return Err(AppError::localized( + "usage_script.total_type_error", + "total 必须是数字或 null", + "total must be number or null", + )); } if obj.contains_key("used") && !result["used"].is_null() && !result["used"].is_number() { - return Err(AppError::localized("usage_script.used_type_error", "used 必须是数字或 null", "used must be number or null")); + return Err(AppError::localized( + "usage_script.used_type_error", + "used 必须是数字或 null", + "used must be number or null", + )); } if obj.contains_key("planName") && !result["planName"].is_null() && !result["planName"].is_string() { - return Err(AppError::localized("usage_script.planname_type_error", "planName 必须是字符串或 null", "planName must be string or null")); + return Err(AppError::localized( + "usage_script.planname_type_error", + "planName 必须是字符串或 null", + "planName must be string or null", + )); } if obj.contains_key("extra") && !result["extra"].is_null() && !result["extra"].is_string() { - return Err(AppError::localized("usage_script.extra_type_error", "extra 必须是字符串或 null", "extra must be string or null")); + return Err(AppError::localized( + "usage_script.extra_type_error", + "extra 必须是字符串或 null", + "extra must be string or null", + )); } Ok(()) diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index c24cc36..4a6eff1 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -240,8 +240,8 @@ fn provider_service_switch_missing_provider_returns_error() { let err = ProviderService::switch(&state, AppType::Claude, "missing") .expect_err("switching missing provider should fail"); match err { - AppError::ProviderNotFound(id) => assert_eq!(id, "missing"), - other => panic!("expected ProviderNotFound, got {other:?}"), + AppError::Localized { key, .. } => assert_eq!(key, "provider.not_found"), + other => panic!("expected Localized error for provider not found, got {other:?}"), } } diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index 51b378c..f3ea02e 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -333,7 +333,7 @@ const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => { function formatRelativeTime( timestamp: number, now: number, - t: (key: string, options?: { count?: number }) => string + t: (key: string, options?: { count?: number }) => string, ): string { const diff = Math.floor((now - timestamp) / 1000); // 秒 diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 4662b0b..a1db595 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -33,7 +33,9 @@ const TEMPLATE_KEYS = { } as const; // 生成预设模板的函数(支持国际化) -const generatePresetTemplates = (t: (key: string) => string): Record => ({ +const generatePresetTemplates = ( + t: (key: string) => string, +): Record => ({ [TEMPLATE_KEYS.CUSTOM]: `({ request: { url: "", @@ -135,7 +137,7 @@ const UsageScriptModal: React.FC = ({ return TEMPLATE_KEYS.NEW_API; } return null; - } + }, ); const handleSave = () => { @@ -165,7 +167,7 @@ const UsageScriptModal: React.FC = ({ script.code, script.timeout, script.accessToken, - script.userId + script.userId, ); if (result.success && result.data && result.data.length > 0) { // 显示所有套餐数据 @@ -183,7 +185,7 @@ const UsageScriptModal: React.FC = ({ `${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`, { duration: 5000, - } + }, ); } } catch (error: any) { @@ -191,7 +193,7 @@ const UsageScriptModal: React.FC = ({ `${t("usageScript.testFailed")}: ${error?.message || t("common.unknown")}`, { duration: 5000, - } + }, ); } finally { setTesting(false); @@ -215,7 +217,7 @@ const UsageScriptModal: React.FC = ({ `${t("usageScript.formatFailed")}: ${error?.message || t("jsonEditor.invalidJson")}`, { duration: 3000, - } + }, ); } }; diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 7c320d5..6aae4f8 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -331,26 +331,34 @@ export function ProviderForm({ // 处理 meta 字段:基于 draftCustomEndpoints 生成 custom_endpoints // 注意:不使用 customEndpointsMap,因为它包含了候选端点(预设、Base URL 等) // 而我们只需要保存用户真正添加的自定义端点 - const customEndpointsToSave: Record | null = + const customEndpointsToSave: Record< + string, + import("@/types").CustomEndpoint + > | null = draftCustomEndpoints.length > 0 - ? draftCustomEndpoints.reduce((acc, url) => { - // 尝试从 initialData.meta 中获取原有的端点元数据(保留 addedAt 和 lastUsed) - const existing = initialData?.meta?.custom_endpoints?.[url]; - if (existing) { - acc[url] = existing; - } else { - // 新端点:使用当前时间戳 - const now = Date.now(); - acc[url] = { url, addedAt: now, lastUsed: undefined }; - } - return acc; - }, {} as Record) + ? draftCustomEndpoints.reduce( + (acc, url) => { + // 尝试从 initialData.meta 中获取原有的端点元数据(保留 addedAt 和 lastUsed) + const existing = initialData?.meta?.custom_endpoints?.[url]; + if (existing) { + acc[url] = existing; + } else { + // 新端点:使用当前时间戳 + const now = Date.now(); + acc[url] = { url, addedAt: now, lastUsed: undefined }; + } + return acc; + }, + {} as Record, + ) : null; // 检测是否需要清空端点(重要:区分"用户清空端点"和"用户没有修改端点") - const hadEndpoints = initialData?.meta?.custom_endpoints && - Object.keys(initialData.meta.custom_endpoints).length > 0; - const needsClearEndpoints = hadEndpoints && draftCustomEndpoints.length === 0; + const hadEndpoints = + initialData?.meta?.custom_endpoints && + Object.keys(initialData.meta.custom_endpoints).length > 0; + const needsClearEndpoints = + hadEndpoints && draftCustomEndpoints.length === 0; // 如果用户明确清空了端点,传递空对象(而不是 null)让后端知道要删除 const mergedMeta = needsClearEndpoints diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 841b3e8..83c1312 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -121,9 +121,7 @@ requires_openai_auth = true`, "https://www.dmxapi.cn/v1", "gpt-5-codex", ), - endpointCandidates: [ - "https://www.dmxapi.cn/v1", - ], + endpointCandidates: ["https://www.dmxapi.cn/v1"], }, { name: "PackyCode", diff --git a/src/lib/api/usage.ts b/src/lib/api/usage.ts index cb145ac..5a4d725 100644 --- a/src/lib/api/usage.ts +++ b/src/lib/api/usage.ts @@ -33,7 +33,7 @@ export const usageApi = { scriptCode: string, timeout?: number, accessToken?: string, - userId?: string + userId?: string, ): Promise { try { return await invoke("testUsageScript", {