From c49cfa5ac58c8a76fec6e71ba0da638d87e31a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E7=BE=BD?= <92211915+qyinter@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:52:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(deeplink):=20=E6=B7=B1=E9=93=BE=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=94=A8=E9=87=8F=E6=9F=A5=E8=AF=A2=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=20(#400)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增功能 - 深链导入支持用量查询配置参数: - `usageEnabled`: 是否启用用量查询 - `usageScript`: Base64 编码的用量查询脚本 - `usageApiKey`: 用量查询专用 API Key - `usageBaseUrl`: 用量查询专用 Base URL - `usageAccessToken`: 访问令牌(NewAPI 模板) - `usageUserId`: 用户 ID(NewAPI 模板) - `usageAutoInterval`: 自动查询间隔(分钟) ## 修改文件 - **mod.rs**: DeepLinkImportRequest 结构体添加用量查询字段 - **parser.rs**: 解析 URL 中的用量查询参数 - **provider.rs**: 构建 ProviderMeta 包含 UsageScript 配置 - **deeplink.ts**: 添加 TypeScript 类型定义 - **DeepLinkImportDialog.tsx**: 确认对话框显示用量查询配置 ## Bug 修复 - **formatters.ts**: 修复 formatJSON() 格式化时删除 "env" 键的问题 ## 深链格式示例 ``` ccswitch://v1/import?resource=provider&app=claude&name=xxx&usageEnabled=true&usageScript={base64}&usageAutoInterval=30 ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- src-tauri/src/deeplink/mod.rs | 23 ++++++ src-tauri/src/deeplink/parser.rs | 41 +++++++++++ src-tauri/src/deeplink/provider.rs | 58 ++++++++++++++- src/components/DeepLinkImportDialog.tsx | 96 ++++++++++++++++++++++--- src/lib/api/deeplink.ts | 13 +++- src/utils/formatters.ts | 7 +- 6 files changed, 222 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index 5c1e1a72..4111089e 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -113,4 +113,27 @@ pub struct DeepLinkImportRequest { /// Remote config URL #[serde(skip_serializing_if = "Option::is_none")] pub config_url: Option, + + // ============ Usage script fields (v3.9+) ============ + /// Whether to enable usage query (default: true if usage_script is provided) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_enabled: Option, + /// Base64 encoded usage query script code + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_script: Option, + /// Usage query API key (if different from provider API key) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_api_key: Option, + /// Usage query base URL (if different from provider endpoint) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_base_url: Option, + /// Usage query access token (for NewAPI template) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_access_token: Option, + /// Usage query user ID (for NewAPI template) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_user_id: Option, + /// Auto query interval in minutes (0 to disable) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_auto_interval: Option, } diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index 316af003..61cf7b68 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -122,6 +122,19 @@ fn parse_provider_deeplink( let config_url = params.get("configUrl").cloned(); let enabled = params.get("enabled").and_then(|v| v.parse::().ok()); + // Extract usage script fields (v3.9+) + let usage_enabled = params + .get("usageEnabled") + .and_then(|v| v.parse::().ok()); + let usage_script = params.get("usageScript").cloned(); + let usage_api_key = params.get("usageApiKey").cloned(); + let usage_base_url = params.get("usageBaseUrl").cloned(); + let usage_access_token = params.get("usageAccessToken").cloned(); + let usage_user_id = params.get("usageUserId").cloned(); + let usage_auto_interval = params + .get("usageAutoInterval") + .and_then(|v| v.parse::().ok()); + Ok(DeepLinkImportRequest { version, resource, @@ -146,6 +159,13 @@ fn parse_provider_deeplink( config, config_format, config_url, + usage_enabled, + usage_script, + usage_api_key, + usage_base_url, + usage_access_token, + usage_user_id, + usage_auto_interval, }) } @@ -206,6 +226,13 @@ fn parse_prompt_deeplink( config: None, config_format: None, config_url: None, + usage_enabled: None, + usage_script: None, + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, }) } @@ -261,6 +288,13 @@ fn parse_mcp_deeplink( directory: None, branch: None, config_url: None, + usage_enabled: None, + usage_script: None, + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, }) } @@ -309,5 +343,12 @@ fn parse_skill_deeplink( config: None, config_format: None, config_url: None, + usage_enabled: None, + usage_script: None, + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, }) } diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 3b6cd863..f1bac94c 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -5,7 +5,7 @@ use super::utils::{decode_base64_param, infer_homepage_from_endpoint}; use super::DeepLinkImportRequest; use crate::error::AppError; -use crate::provider::Provider; +use crate::provider::{Provider, ProviderMeta, UsageScript}; use crate::services::ProviderService; use crate::store::AppState; use crate::AppType; @@ -117,6 +117,9 @@ pub(crate) fn build_provider_from_request( AppType::Gemini => build_gemini_settings(request), }; + // Build usage script configuration if provided + let meta = build_provider_meta(request)?; + let provider = Provider { id: String::new(), // Will be generated by caller name: request.name.clone().unwrap_or_default(), @@ -126,7 +129,7 @@ pub(crate) fn build_provider_from_request( created_at: None, sort_index: None, notes: request.notes.clone(), - meta: None, + meta, icon: request.icon.clone(), icon_color: None, is_proxy_target: None, @@ -135,6 +138,57 @@ pub(crate) fn build_provider_from_request( Ok(provider) } +/// Build provider meta with usage script configuration +fn build_provider_meta(request: &DeepLinkImportRequest) -> Result, AppError> { + // Check if any usage script fields are provided + if request.usage_script.is_none() + && request.usage_enabled.is_none() + && request.usage_api_key.is_none() + && request.usage_base_url.is_none() + && request.usage_access_token.is_none() + && request.usage_user_id.is_none() + && request.usage_auto_interval.is_none() + { + return Ok(None); + } + + // Decode usage script code if provided + let code = if let Some(script_b64) = &request.usage_script { + let decoded = decode_base64_param("usage_script", script_b64)?; + String::from_utf8(decoded) + .map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))? + } else { + String::new() + }; + + // Determine enabled state: explicit param > has code > false + let enabled = request.usage_enabled.unwrap_or(!code.is_empty()); + + // Build UsageScript - use provider's API key and endpoint as defaults + let usage_script = UsageScript { + enabled, + language: "javascript".to_string(), + code, + timeout: Some(10), + api_key: request + .usage_api_key + .clone() + .or_else(|| request.api_key.clone()), + base_url: request + .usage_base_url + .clone() + .or_else(|| request.endpoint.clone()), + access_token: request.usage_access_token.clone(), + user_id: request.usage_user_id.clone(), + auto_query_interval: request.usage_auto_interval, + }; + + Ok(Some(ProviderMeta { + usage_script: Some(usage_script), + ..Default::default() + })) +} + /// Build Claude settings configuration fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value { let mut env = serde_json::Map::new(); diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 96035e38..5f6f9cc6 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -32,7 +32,7 @@ export function DeepLinkImportDialog() { // 容错判断:MCP 导入结果可能缺少 type 字段 const isMcpImportResult = ( - value: unknown, + value: unknown ): value is { importedCount: number; importedIds: string[]; @@ -59,7 +59,7 @@ export function DeepLinkImportDialog() { if (event.payload.config || event.payload.configUrl) { try { const mergedRequest = await deeplinkApi.mergeDeeplinkConfig( - event.payload, + event.payload ); console.log("Config merged successfully:", mergedRequest); setRequest(mergedRequest); @@ -77,7 +77,7 @@ export function DeepLinkImportDialog() { } setIsOpen(true); - }, + } ); // Listen for deep link error events @@ -148,7 +148,7 @@ export function DeepLinkImportDialog() { window.dispatchEvent( new CustomEvent("prompt-imported", { detail: { app: request.app }, - }), + }) ); toast.success(t("deeplink.promptImportSuccess"), { description: t("deeplink.promptImportSuccessDescription", { @@ -279,7 +279,7 @@ export function DeepLinkImportDialog() { const maskValue = (key: string, value: string): string => { const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"]; const isSensitive = sensitiveKeys.some((k) => - key.toUpperCase().includes(k), + key.toUpperCase().includes(k) ); if (isSensitive && value.length > 8) { return `${value.substring(0, 8)}${"*".repeat(12)}`; @@ -521,7 +521,7 @@ export function DeepLinkImportDialog() { {maskValue(key, String(value))} - ), + ) )} )} @@ -548,7 +548,7 @@ export function DeepLinkImportDialog() { {maskValue(key, String(value))} - ), + ) )} )} @@ -584,7 +584,7 @@ export function DeepLinkImportDialog() { {maskValue(key, String(value))} - ), + ) )} )} @@ -605,6 +605,86 @@ export function DeepLinkImportDialog() { )} + {/* Usage Script Configuration (v3.9+) */} + {request.usageScript && ( +
+
+
+ {t("deeplink.usageScript", { + defaultValue: "用量查询", + })} +
+
+ + {request.usageEnabled !== false + ? t("deeplink.usageScriptEnabled", { + defaultValue: "已启用", + }) + : t("deeplink.usageScriptDisabled", { + defaultValue: "未启用", + })} + +
+
+ + {/* Usage API Key (if different from provider) */} + {request.usageApiKey && + request.usageApiKey !== request.apiKey && ( +
+
+ {t("deeplink.usageApiKey", { + defaultValue: "用量 API Key", + })} +
+
+ {request.usageApiKey.length > 4 + ? `${request.usageApiKey.substring(0, 4)}${"*".repeat(12)}` + : "****"} +
+
+ )} + + {/* Usage Base URL (if different from provider) */} + {request.usageBaseUrl && + request.usageBaseUrl !== request.endpoint && ( +
+
+ {t("deeplink.usageBaseUrl", { + defaultValue: "用量查询地址", + })} +
+
+ {request.usageBaseUrl} +
+
+ )} + + {/* Auto Query Interval */} + {request.usageAutoInterval && + request.usageAutoInterval > 0 && ( +
+
+ {t("deeplink.usageAutoInterval", { + defaultValue: "自动查询", + })} +
+
+ {t("deeplink.usageAutoIntervalValue", { + defaultValue: "每 {{minutes}} 分钟", + minutes: request.usageAutoInterval, + })} +
+
+ )} +
+ )} + {/* Warning */}
{t("deeplink.warning")} diff --git a/src/lib/api/deeplink.ts b/src/lib/api/deeplink.ts index 003c19f6..6b03175f 100644 --- a/src/lib/api/deeplink.ts +++ b/src/lib/api/deeplink.ts @@ -38,6 +38,15 @@ export interface DeepLinkImportRequest { config?: string; configFormat?: string; configUrl?: string; + + // Usage script fields (v3.9+) + usageEnabled?: boolean; + usageScript?: string; + usageApiKey?: string; + usageBaseUrl?: string; + usageAccessToken?: string; + usageUserId?: string; + usageAutoInterval?: number; } export interface McpImportResult { @@ -77,7 +86,7 @@ export const deeplinkApi = { * @returns Merged deep link request with config fields populated */ mergeDeeplinkConfig: async ( - request: DeepLinkImportRequest, + request: DeepLinkImportRequest ): Promise => { return invoke("merge_deeplink_config", { request }); }, @@ -88,7 +97,7 @@ export const deeplinkApi = { * @returns Import result based on resource type */ importFromDeeplink: async ( - request: DeepLinkImportRequest, + request: DeepLinkImportRequest ): Promise => { return invoke("import_from_deeplink_unified", { request }); }, diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index 0f5a52c6..04e23a95 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -1,6 +1,6 @@ /** * 格式化 JSON 字符串 - * @param value - 原始 JSON 字符串(支持带键名包装的格式) + * @param value - 原始 JSON 字符串 * @returns 格式化后的 JSON 字符串(2 空格缩进) * @throws 如果 JSON 格式无效 */ @@ -9,9 +9,8 @@ export function formatJSON(value: string): string { if (!trimmed) { return ""; } - // 使用智能解析器来处理可能的片段格式 - const result = parseSmartMcpJson(trimmed); - return result.formattedConfig; + const parsed = JSON.parse(trimmed); + return JSON.stringify(parsed, null, 2); } /**