feat(deeplink): 深链支持用量查询配置 (#400)

## 新增功能
- 深链导入支持用量查询配置参数:
  - `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 <noreply@anthropic.com>
This commit is contained in:
千羽
2025-12-14 22:52:13 +09:00
committed by GitHub
parent bfe9bb6a0c
commit c49cfa5ac5
6 changed files with 222 additions and 16 deletions

View File

@@ -113,4 +113,27 @@ pub struct DeepLinkImportRequest {
/// Remote config URL /// Remote config URL
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub config_url: Option<String>, pub config_url: Option<String>,
// ============ 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<bool>,
/// Base64 encoded usage query script code
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<String>,
/// Usage query API key (if different from provider API key)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_api_key: Option<String>,
/// Usage query base URL (if different from provider endpoint)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_base_url: Option<String>,
/// Usage query access token (for NewAPI template)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_access_token: Option<String>,
/// Usage query user ID (for NewAPI template)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_user_id: Option<String>,
/// Auto query interval in minutes (0 to disable)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_auto_interval: Option<u64>,
} }

View File

@@ -122,6 +122,19 @@ fn parse_provider_deeplink(
let config_url = params.get("configUrl").cloned(); let config_url = params.get("configUrl").cloned();
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok()); let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
// Extract usage script fields (v3.9+)
let usage_enabled = params
.get("usageEnabled")
.and_then(|v| v.parse::<bool>().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::<u64>().ok());
Ok(DeepLinkImportRequest { Ok(DeepLinkImportRequest {
version, version,
resource, resource,
@@ -146,6 +159,13 @@ fn parse_provider_deeplink(
config, config,
config_format, config_format,
config_url, 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: None,
config_format: None, config_format: None,
config_url: 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, directory: None,
branch: None, branch: None,
config_url: 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: None,
config_format: None, config_format: None,
config_url: 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,
}) })
} }

View File

@@ -5,7 +5,7 @@
use super::utils::{decode_base64_param, infer_homepage_from_endpoint}; use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
use super::DeepLinkImportRequest; use super::DeepLinkImportRequest;
use crate::error::AppError; use crate::error::AppError;
use crate::provider::Provider; use crate::provider::{Provider, ProviderMeta, UsageScript};
use crate::services::ProviderService; use crate::services::ProviderService;
use crate::store::AppState; use crate::store::AppState;
use crate::AppType; use crate::AppType;
@@ -117,6 +117,9 @@ pub(crate) fn build_provider_from_request(
AppType::Gemini => build_gemini_settings(request), AppType::Gemini => build_gemini_settings(request),
}; };
// Build usage script configuration if provided
let meta = build_provider_meta(request)?;
let provider = Provider { let provider = Provider {
id: String::new(), // Will be generated by caller id: String::new(), // Will be generated by caller
name: request.name.clone().unwrap_or_default(), name: request.name.clone().unwrap_or_default(),
@@ -126,7 +129,7 @@ pub(crate) fn build_provider_from_request(
created_at: None, created_at: None,
sort_index: None, sort_index: None,
notes: request.notes.clone(), notes: request.notes.clone(),
meta: None, meta,
icon: request.icon.clone(), icon: request.icon.clone(),
icon_color: None, icon_color: None,
is_proxy_target: None, is_proxy_target: None,
@@ -135,6 +138,57 @@ pub(crate) fn build_provider_from_request(
Ok(provider) Ok(provider)
} }
/// Build provider meta with usage script configuration
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, 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 /// Build Claude settings configuration
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value { fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let mut env = serde_json::Map::new(); let mut env = serde_json::Map::new();

View File

@@ -32,7 +32,7 @@ export function DeepLinkImportDialog() {
// 容错判断MCP 导入结果可能缺少 type 字段 // 容错判断MCP 导入结果可能缺少 type 字段
const isMcpImportResult = ( const isMcpImportResult = (
value: unknown, value: unknown
): value is { ): value is {
importedCount: number; importedCount: number;
importedIds: string[]; importedIds: string[];
@@ -59,7 +59,7 @@ export function DeepLinkImportDialog() {
if (event.payload.config || event.payload.configUrl) { if (event.payload.config || event.payload.configUrl) {
try { try {
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig( const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
event.payload, event.payload
); );
console.log("Config merged successfully:", mergedRequest); console.log("Config merged successfully:", mergedRequest);
setRequest(mergedRequest); setRequest(mergedRequest);
@@ -77,7 +77,7 @@ export function DeepLinkImportDialog() {
} }
setIsOpen(true); setIsOpen(true);
}, }
); );
// Listen for deep link error events // Listen for deep link error events
@@ -148,7 +148,7 @@ export function DeepLinkImportDialog() {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("prompt-imported", { new CustomEvent("prompt-imported", {
detail: { app: request.app }, detail: { app: request.app },
}), })
); );
toast.success(t("deeplink.promptImportSuccess"), { toast.success(t("deeplink.promptImportSuccess"), {
description: t("deeplink.promptImportSuccessDescription", { description: t("deeplink.promptImportSuccessDescription", {
@@ -279,7 +279,7 @@ export function DeepLinkImportDialog() {
const maskValue = (key: string, value: string): string => { const maskValue = (key: string, value: string): string => {
const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"]; const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"];
const isSensitive = sensitiveKeys.some((k) => const isSensitive = sensitiveKeys.some((k) =>
key.toUpperCase().includes(k), key.toUpperCase().includes(k)
); );
if (isSensitive && value.length > 8) { if (isSensitive && value.length > 8) {
return `${value.substring(0, 8)}${"*".repeat(12)}`; return `${value.substring(0, 8)}${"*".repeat(12)}`;
@@ -521,7 +521,7 @@ export function DeepLinkImportDialog() {
{maskValue(key, String(value))} {maskValue(key, String(value))}
</span> </span>
</div> </div>
), )
)} )}
</div> </div>
)} )}
@@ -548,7 +548,7 @@ export function DeepLinkImportDialog() {
{maskValue(key, String(value))} {maskValue(key, String(value))}
</span> </span>
</div> </div>
), )
)} )}
</div> </div>
)} )}
@@ -584,7 +584,7 @@ export function DeepLinkImportDialog() {
{maskValue(key, String(value))} {maskValue(key, String(value))}
</span> </span>
</div> </div>
), )
)} )}
</div> </div>
)} )}
@@ -605,6 +605,86 @@ export function DeepLinkImportDialog() {
</div> </div>
)} )}
{/* Usage Script Configuration (v3.9+) */}
{request.usageScript && (
<div className="space-y-3 pt-2 border-t border-border-default">
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageScript", {
defaultValue: "用量查询",
})}
</div>
<div className="col-span-2 text-sm">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium ${
request.usageEnabled !== false
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{request.usageEnabled !== false
? t("deeplink.usageScriptEnabled", {
defaultValue: "已启用",
})
: t("deeplink.usageScriptDisabled", {
defaultValue: "未启用",
})}
</span>
</div>
</div>
{/* Usage API Key (if different from provider) */}
{request.usageApiKey &&
request.usageApiKey !== request.apiKey && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageApiKey", {
defaultValue: "用量 API Key",
})}
</div>
<div className="col-span-2 text-sm font-mono text-muted-foreground">
{request.usageApiKey.length > 4
? `${request.usageApiKey.substring(0, 4)}${"*".repeat(12)}`
: "****"}
</div>
</div>
)}
{/* Usage Base URL (if different from provider) */}
{request.usageBaseUrl &&
request.usageBaseUrl !== request.endpoint && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageBaseUrl", {
defaultValue: "用量查询地址",
})}
</div>
<div className="col-span-2 text-sm break-all">
{request.usageBaseUrl}
</div>
</div>
)}
{/* Auto Query Interval */}
{request.usageAutoInterval &&
request.usageAutoInterval > 0 && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageAutoInterval", {
defaultValue: "自动查询",
})}
</div>
<div className="col-span-2 text-sm">
{t("deeplink.usageAutoIntervalValue", {
defaultValue: "每 {{minutes}} 分钟",
minutes: request.usageAutoInterval,
})}
</div>
</div>
)}
</div>
)}
{/* Warning */} {/* Warning */}
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200"> <div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
{t("deeplink.warning")} {t("deeplink.warning")}

View File

@@ -38,6 +38,15 @@ export interface DeepLinkImportRequest {
config?: string; config?: string;
configFormat?: string; configFormat?: string;
configUrl?: 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 { export interface McpImportResult {
@@ -77,7 +86,7 @@ export const deeplinkApi = {
* @returns Merged deep link request with config fields populated * @returns Merged deep link request with config fields populated
*/ */
mergeDeeplinkConfig: async ( mergeDeeplinkConfig: async (
request: DeepLinkImportRequest, request: DeepLinkImportRequest
): Promise<DeepLinkImportRequest> => { ): Promise<DeepLinkImportRequest> => {
return invoke("merge_deeplink_config", { request }); return invoke("merge_deeplink_config", { request });
}, },
@@ -88,7 +97,7 @@ export const deeplinkApi = {
* @returns Import result based on resource type * @returns Import result based on resource type
*/ */
importFromDeeplink: async ( importFromDeeplink: async (
request: DeepLinkImportRequest, request: DeepLinkImportRequest
): Promise<ImportResult> => { ): Promise<ImportResult> => {
return invoke("import_from_deeplink_unified", { request }); return invoke("import_from_deeplink_unified", { request });
}, },

View File

@@ -1,6 +1,6 @@
/** /**
* 格式化 JSON 字符串 * 格式化 JSON 字符串
* @param value - 原始 JSON 字符串(支持带键名包装的格式) * @param value - 原始 JSON 字符串
* @returns 格式化后的 JSON 字符串2 空格缩进) * @returns 格式化后的 JSON 字符串2 空格缩进)
* @throws 如果 JSON 格式无效 * @throws 如果 JSON 格式无效
*/ */
@@ -9,9 +9,8 @@ export function formatJSON(value: string): string {
if (!trimmed) { if (!trimmed) {
return ""; return "";
} }
// 使用智能解析器来处理可能的片段格式 const parsed = JSON.parse(trimmed);
const result = parseSmartMcpJson(trimmed); return JSON.stringify(parsed, null, 2);
return result.formattedConfig;
} }
/** /**