mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-21 11:46:49 +08:00
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:
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user