diff --git a/.gitignore b/.gitignore index 0d771aa..6626d7d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ release/ CLAUDE.md AGENTS.md /.claude +/.vscode diff --git a/package.json b/package.json index 43169af..7577bcd 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "vite": "^5.0.0" }, "dependencies": { + "@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-json": "^6.0.2", "@codemirror/lint": "^6.8.5", "@codemirror/state": "^6.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c01abf3..590f7e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@codemirror/lang-javascript': + specifier: ^6.2.4 + version: 6.2.4 '@codemirror/lang-json': specifier: ^6.0.2 version: 6.0.2 @@ -190,6 +193,9 @@ packages: '@codemirror/commands@6.8.1': resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==} + '@codemirror/lang-javascript@6.2.4': + resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} + '@codemirror/lang-json@6.0.2': resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} @@ -378,6 +384,9 @@ packages: '@lezer/highlight@1.2.1': resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==} + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + '@lezer/json@1.0.3': resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} @@ -1190,6 +1199,16 @@ snapshots: '@codemirror/view': 6.38.2 '@lezer/common': 1.2.3 + '@codemirror/lang-javascript@6.2.4': + dependencies: + '@codemirror/autocomplete': 6.18.7 + '@codemirror/language': 6.11.3 + '@codemirror/lint': 6.8.5 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.2 + '@lezer/common': 1.2.3 + '@lezer/javascript': 1.5.4 + '@codemirror/lang-json@6.0.2': dependencies: '@codemirror/language': 6.11.3 @@ -1334,6 +1353,12 @@ snapshots: dependencies: '@lezer/common': 1.2.3 + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.2.3 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + '@lezer/json@1.0.3': dependencies: '@lezer/common': 1.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fd050a4..fab9eb3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ +packages: [] + onlyBuiltDependencies: - '@tailwindcss/oxide' diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0e796e3..27e0a9b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -571,7 +571,9 @@ dependencies = [ "log", "objc2 0.5.2", "objc2-app-kit 0.2.2", + "regex", "reqwest", + "rquickjs", "serde", "serde_json", "tauri", @@ -3590,6 +3592,33 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rquickjs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16661bff09e9ed8e01094a188b463de45ec0693ade55b92ed54027d7ba7c40c" +dependencies = [ + "rquickjs-core", +] + +[[package]] +name = "rquickjs-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8db6379e204ef84c0811e90e7cc3e3e4d7688701db68a00d14a6db6849087b" +dependencies = [ + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bc352c6b663604c3c186c000cfcc6c271f4b50bc135a285dd6d4f2a42f9790a" +dependencies = [ + "cc", +] + [[package]] name = "rust_decimal" version = "1.38.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2968263..0b2a9ae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,9 +30,11 @@ tauri-plugin-updater = "2" tauri-plugin-dialog = "2" dirs = "5.0" toml = "0.8" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } futures = "0.3" +regex = "1.10" +rquickjs = { version = "0.8", features = ["array-buffer", "classes"] } [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9772572..5a3ec2a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -246,6 +246,7 @@ pub async fn update_provider( } updated.meta = Some(crate::provider::ProviderMeta { custom_endpoints: merged_map, + usage_script: new_meta.usage_script.clone(), }); } // 旧 meta 不存在:使用入参(可能为 None) @@ -798,6 +799,172 @@ pub async fn validate_mcp_command(cmd: String) -> Result { claude_mcp::validate_command_in_path(&cmd) } +// ===================== +// 用量查询命令 +// ===================== + +/// 查询供应商用量 +#[tauri::command] +pub async fn query_provider_usage( + state: State<'_, AppState>, + provider_id: Option, + providerId: Option, + app_type: Option, + app: Option, + appType: Option, +) -> Result { + use crate::provider::{UsageData, UsageResult}; + + // 解析参数 + let provider_id = provider_id + .or(providerId) + .ok_or("缺少 providerId 参数")?; + + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + + // 1. 获取供应商配置并克隆所需数据 + let (api_key, base_url, usage_script_code, timeout) = { + let config = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + + let manager = config + .get_manager(&app_type) + .ok_or("应用类型不存在")?; + + let provider = manager + .providers + .get(&provider_id) + .ok_or("供应商不存在")?; + + // 2. 检查脚本配置 + let usage_script = provider + .meta + .as_ref() + .and_then(|m| m.usage_script.as_ref()) + .ok_or("未配置用量查询脚本")?; + + if !usage_script.enabled { + return Err("用量查询未启用".to_string()); + } + + // 3. 提取凭证和脚本配置 + let (api_key, base_url) = extract_credentials(provider, &app_type)?; + let timeout = usage_script.timeout.unwrap_or(10); + let code = usage_script.code.clone(); + + // 显式释放锁 + drop(config); + + (api_key, base_url, code, timeout) + }; + + // 5. 执行脚本 + let result = crate::usage_script::execute_usage_script( + &usage_script_code, + &api_key, + &base_url, + timeout, + ) + .await; + + // 6. 构建结果(支持单对象或数组) + match result { + Ok(data) => { + // 尝试解析为数组 + let usage_list: Vec = if data.is_array() { + // 直接解析为数组 + serde_json::from_value(data) + .map_err(|e| format!("数据格式错误: {}", e))? + } else { + // 单对象包装为数组(向后兼容) + let single: UsageData = serde_json::from_value(data) + .map_err(|e| format!("数据格式错误: {}", e))?; + vec![single] + }; + + Ok(UsageResult { + success: true, + data: Some(usage_list), + error: None, + }) + } + Err(e) => { + Ok(UsageResult { + success: false, + data: None, + error: Some(e), + }) + } + } +} + +/// 从供应商配置中提取 API Key 和 Base URL +fn extract_credentials( + provider: &crate::provider::Provider, + app_type: &AppType, +) -> Result<(String, String), String> { + match app_type { + AppType::Claude => { + let env = provider + .settings_config + .get("env") + .and_then(|v| v.as_object()) + .ok_or("配置格式错误: 缺少 env")?; + + let api_key = env + .get("ANTHROPIC_AUTH_TOKEN") + .and_then(|v| v.as_str()) + .ok_or("缺少 API Key")? + .to_string(); + + let base_url = env + .get("ANTHROPIC_BASE_URL") + .and_then(|v| v.as_str()) + .ok_or("缺少 ANTHROPIC_BASE_URL 配置")? + .to_string(); + + Ok((api_key, base_url)) + } + AppType::Codex => { + let auth = provider + .settings_config + .get("auth") + .and_then(|v| v.as_object()) + .ok_or("配置格式错误: 缺少 auth")?; + + let api_key = auth + .get("OPENAI_API_KEY") + .and_then(|v| v.as_str()) + .ok_or("缺少 API Key")? + .to_string(); + + // 从 config TOML 中提取 base_url + let config_toml = provider + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let base_url = if config_toml.contains("base_url") { + let re = regex::Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).unwrap(); + re.captures(config_toml) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().to_string()) + .ok_or("config.toml 中 base_url 格式错误")? + } else { + return Err("config.toml 中缺少 base_url 配置".to_string()); + }; + + Ok((api_key, base_url)) + } + } +} + // ===================== // 新:集中以 config.json 为 SSOT 的 MCP 配置命令 // ===================== diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0fba7c5..c55952c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod migration; mod provider; mod settings; mod speedtest; +mod usage_script; mod store; use store::AppState; @@ -429,6 +430,8 @@ pub fn run() { commands::upsert_claude_mcp_server, commands::delete_claude_mcp_server, commands::validate_mcp_command, + // usage query + commands::query_provider_usage, // New MCP via config.json (SSOT) commands::get_mcp_config, commands::upsert_mcp_server_in_config, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index ee83425..bda9127 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -51,12 +51,59 @@ pub struct ProviderManager { pub current: String, } +/// 用量查询脚本配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageScript { + pub enabled: bool, + pub language: String, + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +/// 用量数据 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageData { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "planName")] + pub plan_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub extra: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "isValid")] + pub is_valid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "invalidMessage")] + pub invalid_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub total: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub used: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remaining: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option, +} + +/// 用量查询结果(支持多套餐) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageResult { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option>, // 支持返回多个套餐 + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + /// 供应商元数据 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProviderMeta { /// 自定义端点列表(按 URL 去重存储) #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub custom_endpoints: HashMap, + /// 用量查询脚本配置 + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_script: Option, } impl ProviderManager { diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs new file mode 100644 index 0000000..50fb49f --- /dev/null +++ b/src-tauri/src/usage_script.rs @@ -0,0 +1,207 @@ +use reqwest::Client; +use rquickjs::{Context, Runtime, Function}; +use serde_json::Value; +use std::collections::HashMap; +use std::time::Duration; + +/// 执行用量查询脚本 +pub async fn execute_usage_script( + script_code: &str, + api_key: &str, + base_url: &str, + timeout_secs: u64, +) -> Result { + // 1. 替换变量 + let replaced = script_code + .replace("{{apiKey}}", api_key) + .replace("{{baseUrl}}", base_url); + + // 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放) + let request_config = { + let runtime = Runtime::new().map_err(|e| format!("创建 JS 运行时失败: {}", e))?; + let context = Context::full(&runtime).map_err(|e| format!("创建 JS 上下文失败: {}", e))?; + + context.with(|ctx| { + // 执行用户代码,获取配置对象 + let config: rquickjs::Object = ctx + .eval(replaced.clone()) + .map_err(|e| format!("解析配置失败: {}", e))?; + + // 提取 request 配置 + let request: rquickjs::Object = config + .get("request") + .map_err(|e| format!("缺少 request 配置: {}", e))?; + + // 将 request 转换为 JSON 字符串 + let request_json: String = ctx + .json_stringify(request) + .map_err(|e| format!("序列化 request 失败: {}", e))? + .ok_or("序列化返回 None")? + .get() + .map_err(|e| format!("获取字符串失败: {}", e))?; + + Ok::<_, String>(request_json) + })? + }; // Runtime 和 Context 在这里被 drop + + // 3. 解析 request 配置 + let request: RequestConfig = serde_json::from_str(&request_config) + .map_err(|e| format!("request 配置格式错误: {}", 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| format!("创建 JS 运行时失败: {}", e))?; + let context = Context::full(&runtime).map_err(|e| format!("创建 JS 上下文失败: {}", e))?; + + context.with(|ctx| { + // 重新 eval 获取配置对象 + let config: rquickjs::Object = ctx + .eval(replaced.clone()) + .map_err(|e| format!("重新解析配置失败: {}", e))?; + + // 提取 extractor 函数 + let extractor: Function = config + .get("extractor") + .map_err(|e| format!("缺少 extractor 函数: {}", e))?; + + // 将响应数据转换为 JS 值 + let response_js: rquickjs::Value = ctx + .json_parse(response_data.as_str()) + .map_err(|e| format!("解析响应 JSON 失败: {}", e))?; + + // 调用 extractor(response) + let result_js: rquickjs::Value = extractor + .call((response_js,)) + .map_err(|e| format!("执行 extractor 失败: {}", e))?; + + // 转换为 JSON 字符串 + let result_json: String = ctx + .json_stringify(result_js) + .map_err(|e| format!("序列化结果失败: {}", e))? + .ok_or("序列化返回 None")? + .get() + .map_err(|e| format!("获取字符串失败: {}", e))?; + + // 解析为 serde_json::Value + serde_json::from_str(&result_json).map_err(|e| format!("JSON 解析失败: {}", e)) + })? + }; // Runtime 和 Context 在这里被 drop + + // 6. 验证返回值格式 + validate_result(&result)?; + + Ok(result) +} + +/// 请求配置结构 +#[derive(Debug, serde::Deserialize)] +struct RequestConfig { + url: String, + method: String, + #[serde(default)] + headers: HashMap, + #[serde(default)] + body: Option, +} + +/// 发送 HTTP 请求 +async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| format!("创建客户端失败: {}", e))?; + + let method = config + .method + .parse() + .unwrap_or(reqwest::Method::GET); + + let mut req = client.request(method.clone(), &config.url); + + // 添加请求头 + for (k, v) in &config.headers { + req = req.header(k, v); + } + + // 添加请求体 + if let Some(body) = &config.body { + req = req.body(body.clone()); + } + + // 发送请求 + let resp = req + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| format!("读取响应失败: {}", e))?; + + if !status.is_success() { + let preview = if text.len() > 200 { + format!("{}...", &text[..200]) + } else { + text.clone() + }; + return Err(format!("HTTP {} : {}", status, preview)); + } + + Ok(text) +} + +/// 验证脚本返回值(支持单对象或数组) +fn validate_result(result: &Value) -> Result<(), String> { + // 如果是数组,验证每个元素 + if let Some(arr) = result.as_array() { + if arr.is_empty() { + return Err("脚本返回的数组不能为空".to_string()); + } + for (idx, item) in arr.iter().enumerate() { + validate_single_usage(item) + .map_err(|e| format!("数组索引[{}]验证失败: {}", idx, e))?; + } + return Ok(()); + } + + // 如果是单对象,直接验证(向后兼容) + validate_single_usage(result) +} + +/// 验证单个用量数据对象 +fn validate_single_usage(result: &Value) -> Result<(), String> { + let obj = result.as_object().ok_or("脚本必须返回对象或对象数组")?; + + // 所有字段均为可选,只进行类型检查 + if obj.contains_key("isValid") && !result["isValid"].is_null() && !result["isValid"].is_boolean() { + return Err("isValid 必须是布尔值或 null".to_string()); + } + if obj.contains_key("invalidMessage") && !result["invalidMessage"].is_null() && !result["invalidMessage"].is_string() { + return Err("invalidMessage 必须是字符串或 null".to_string()); + } + if obj.contains_key("remaining") && !result["remaining"].is_null() && !result["remaining"].is_number() { + return Err("remaining 必须是数字或 null".to_string()); + } + if obj.contains_key("unit") && !result["unit"].is_null() && !result["unit"].is_string() { + return Err("unit 必须是字符串或 null".to_string()); + } + if obj.contains_key("total") && !result["total"].is_null() && !result["total"].is_number() { + return Err("total 必须是数字或 null".to_string()); + } + if obj.contains_key("used") && !result["used"].is_null() && !result["used"].is_number() { + return Err("used 必须是数字或 null".to_string()); + } + if obj.contains_key("planName") && !result["planName"].is_null() && !result["planName"].is_string() { + return Err("planName 必须是字符串或 null".to_string()); + } + if obj.contains_key("extra") && !result["extra"].is_null() && !result["extra"].is_string() { + return Err("extra 必须是字符串或 null".to_string()); + } + + Ok(()) +} diff --git a/src/App.tsx b/src/App.tsx index 74a793c..c50d259 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -354,7 +354,9 @@ function App() { onSwitch={handleSwitchProvider} onDelete={handleDeleteProvider} onEdit={setEditingProviderId} + appType={activeApp} onNotify={showNotification} + onProvidersUpdated={loadProviders} /> diff --git a/src/components/JsonEditor.tsx b/src/components/JsonEditor.tsx index 39a8420..6da1f8c 100644 --- a/src/components/JsonEditor.tsx +++ b/src/components/JsonEditor.tsx @@ -1,6 +1,7 @@ import React, { useRef, useEffect, useMemo } from "react"; import { EditorView, basicSetup } from "codemirror"; import { json } from "@codemirror/lang-json"; +import { javascript } from "@codemirror/lang-javascript"; import { oneDark } from "@codemirror/theme-one-dark"; import { EditorState } from "@codemirror/state"; import { placeholder } from "@codemirror/view"; @@ -14,6 +15,8 @@ interface JsonEditorProps { darkMode?: boolean; rows?: number; showValidation?: boolean; + language?: "json" | "javascript"; + height?: string; } const JsonEditor: React.FC = ({ @@ -23,6 +26,8 @@ const JsonEditor: React.FC = ({ darkMode = false, rows = 12, showValidation = true, + language = "json", + height, }) => { const { t } = useTranslation(); const editorRef = useRef(null); @@ -33,7 +38,7 @@ const JsonEditor: React.FC = ({ () => linter((view) => { const diagnostics: Diagnostic[] = []; - if (!showValidation) return diagnostics; + if (!showValidation || language !== "json") return diagnostics; const doc = view.state.doc.toString(); if (!doc.trim()) return diagnostics; @@ -65,16 +70,16 @@ const JsonEditor: React.FC = ({ return diagnostics; }), - [showValidation, t], + [showValidation, language, t], ); useEffect(() => { if (!editorRef.current) return; // 创建编辑器扩展 - const minHeightPx = Math.max(1, rows) * 18; // 降低最小高度以减少抖动 + const minHeightPx = height ? undefined : Math.max(1, rows) * 18; const sizingTheme = EditorView.theme({ - "&": { minHeight: `${minHeightPx}px` }, + "&": height ? { height } : { minHeight: `${minHeightPx}px` }, ".cm-scroller": { overflow: "auto" }, ".cm-content": { fontFamily: @@ -85,7 +90,7 @@ const JsonEditor: React.FC = ({ const extensions = [ basicSetup, - json(), + language === "javascript" ? javascript() : json(), placeholder(placeholderText || ""), sizingTheme, jsonLinter, @@ -121,7 +126,7 @@ const JsonEditor: React.FC = ({ view.destroy(); viewRef.current = null; }; - }, [darkMode, rows, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建 + }, [darkMode, rows, height, language, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建 // 当 value 从外部改变时更新编辑器内容 useEffect(() => { diff --git a/src/components/ProviderList.tsx b/src/components/ProviderList.tsx index 8b2ddff..46b9c23 100644 --- a/src/components/ProviderList.tsx +++ b/src/components/ProviderList.tsx @@ -1,8 +1,11 @@ -import React from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Provider } from "../types"; -import { Play, Edit3, Trash2, CheckCircle2, Users, Check } from "lucide-react"; +import { Provider, UsageScript } from "../types"; +import { AppType } from "../lib/tauri-api"; +import { Play, Edit3, Trash2, CheckCircle2, Users, Check, BarChart3 } from "lucide-react"; import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles"; +import UsageFooter from "./UsageFooter"; +import UsageScriptModal from "./UsageScriptModal"; // 不再在列表中显示分类徽章,避免造成困惑 interface ProviderListProps { @@ -11,11 +14,13 @@ interface ProviderListProps { onSwitch: (id: string) => void; onDelete: (id: string) => void; onEdit: (id: string) => void; + appType: AppType; onNotify?: ( message: string, type: "success" | "error", duration?: number, ) => void; + onProvidersUpdated?: () => Promise; } const ProviderList: React.FC = ({ @@ -24,9 +29,13 @@ const ProviderList: React.FC = ({ onSwitch, onDelete, onEdit, + appType, onNotify, + onProvidersUpdated, }) => { const { t, i18n } = useTranslation(); + const [usageModalProviderId, setUsageModalProviderId] = useState(null); + // 提取API地址(兼容不同供应商配置:Claude env / Codex TOML) const getApiUrl = (provider: Provider): string => { try { @@ -62,6 +71,29 @@ const ProviderList: React.FC = ({ // 列表页不再提供 Claude 插件按钮,统一在“设置”中控制 + // 处理用量配置保存 + const handleSaveUsageScript = async (providerId: string, script: UsageScript) => { + try { + const provider = providers[providerId]; + const updatedProvider = { + ...provider, + meta: { + ...provider.meta, + usage_script: script, + }, + }; + await window.api.updateProvider(updatedProvider, appType); + onNotify?.("用量查询配置已保存", "success", 2000); + // 重新加载供应商列表,触发 UsageFooter 的 useEffect + if (onProvidersUpdated) { + await onProvidersUpdated(); + } + } catch (error) { + console.error("保存用量配置失败:", error); + onNotify?.("保存失败", "error"); + } + }; + // 对供应商列表进行排序 const sortedProviders = Object.values(providers).sort((a, b) => { // 按添加时间排序 @@ -177,6 +209,15 @@ const ProviderList: React.FC = ({ + {/* 新增:用量配置按钮 */} + + + + {/* 用量信息 Footer */} + ); })} )} + + {/* 用量配置模态框 */} + {usageModalProviderId && providers[usageModalProviderId] && ( + setUsageModalProviderId(null)} + onSave={(script) => + handleSaveUsageScript(usageModalProviderId, script) + } + onNotify={onNotify} + /> + )} ); }; diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx new file mode 100644 index 0000000..03efe80 --- /dev/null +++ b/src/components/UsageFooter.tsx @@ -0,0 +1,210 @@ +import React, { useEffect, useState, useRef } from "react"; +import { UsageResult, UsageData } from "../types"; +import { AppType } from "../lib/tauri-api"; +import { RefreshCw, AlertCircle } from "lucide-react"; + +interface UsageFooterProps { + providerId: string; + appType: AppType; + usageEnabled: boolean; // 是否启用了用量查询 +} + +const UsageFooter: React.FC = ({ + providerId, + appType, + usageEnabled, +}) => { + const [usage, setUsage] = useState(null); + const [loading, setLoading] = useState(false); + + // 记录上次请求的关键参数,防止重复请求 + const lastFetchParamsRef = useRef(''); + + const fetchUsage = async () => { + // 防止并发请求 + if (loading) return; + + setLoading(true); + try { + const result = await window.api.queryProviderUsage( + providerId, + appType + ); + setUsage(result); + } catch (error: any) { + console.error("查询用量失败:", error); + setUsage({ + success: false, + error: error?.message || "查询失败", + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (usageEnabled) { + // 生成当前参数的唯一标识(包含 usageEnabled 状态) + const currentParams = `${providerId}-${appType}-${usageEnabled}`; + + // 只有参数真正变化时才发起请求 + if (currentParams !== lastFetchParamsRef.current) { + lastFetchParamsRef.current = currentParams; + fetchUsage(); + } + } else { + // 如果禁用了,清空记录和数据 + lastFetchParamsRef.current = ''; + setUsage(null); + } + }, [providerId, usageEnabled, appType]); + + // 只在启用用量查询且有数据时显示 + if (!usageEnabled || !usage) return null; + + // 错误状态 + if (!usage.success) { + return ( +
+
+
+ + {usage.error || "查询失败"} +
+ + {/* 刷新按钮 */} + +
+
+ ); + } + + const usageDataList = usage.data || []; + + // 无数据时不显示 + if (usageDataList.length === 0) return null; + + return ( +
+ {/* 标题行:包含刷新按钮 */} +
+ + 套餐用量 + + +
+ + {/* 套餐列表 */} +
+ {usageDataList.map((usageData, index) => ( + + ))} +
+
+ ); +}; + +// 单个套餐数据展示组件 +const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => { + const { planName, extra, isValid, invalidMessage, total, used, remaining, unit } = data; + + // 判断套餐是否失效(isValid 为 false 或未定义时视为有效) + const isExpired = isValid === false; + + return ( +
+ {/* 标题部分:25% */} +
+ {planName ? ( + + 💰 {planName} + + ) : ( + + )} +
+ + {/* 扩展字段:30% */} +
+ {extra && ( + + {extra} + + )} + {isExpired && ( + + {invalidMessage || "已失效"} + + )} +
+ + {/* 用量信息:45% */} +
+ {/* 总额度 */} + {total !== undefined && ( + <> + 总: + + {total === -1 ? "∞" : total.toFixed(2)} + + | + + )} + + {/* 已用额度 */} + {used !== undefined && ( + <> + 使用: + + {used.toFixed(2)} + + | + + )} + + {/* 剩余额度 - 突出显示 */} + {remaining !== undefined && ( + <> + 剩余: + + {remaining.toFixed(2)} + + + )} + + {unit && {unit}} +
+
+ ); +}; + + +export default UsageFooter; diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx new file mode 100644 index 0000000..fe5ef50 --- /dev/null +++ b/src/components/UsageScriptModal.tsx @@ -0,0 +1,355 @@ +import React, { useState } from "react"; +import { X, Play, Wand2 } from "lucide-react"; +import { Provider, UsageScript } from "../types"; +import { AppType } from "../lib/tauri-api"; +import JsonEditor from "./JsonEditor"; +import * as prettier from "prettier/standalone"; +import * as parserBabel from "prettier/parser-babel"; +import * as pluginEstree from "prettier/plugins/estree"; + +interface UsageScriptModalProps { + provider: Provider; + appType: AppType; + onClose: () => void; + onSave: (script: UsageScript) => void; + onNotify?: ( + message: string, + type: "success" | "error", + duration?: number + ) => void; +} + +// 预设模板(JS 对象字面量格式) +const PRESET_TEMPLATES: Record = { + 通用模板: `({ + request: { + url: "{{baseUrl}}/user/balance", + method: "GET", + headers: { + "Authorization": "Bearer {{apiKey}}", + "User-Agent": "cc-switch/1.0" + } + }, + extractor: function(response) { + return { + isValid: response.is_active || true, + remaining: response.balance, + unit: "USD" + }; + } +})`, + + NewAPI: `({ + request: { + url: "{{baseUrl}}/api/usage/token", + method: "GET", + headers: { + Authorization: "Bearer {{apiKey}}", + }, + }, + extractor: function (response) { + if (response.code) { + if (response.data.unlimited_quota) { + return { + planName: response.data.name, + total: -1, + used: response.data.total_used / 500000, + unit: "USD", + }; + } + return { + isValid: true, + planName: response.data.name, + total: response.data.total_granted / 500000, + used: response.data.total_used / 500000, + remaining: response.data.total_available / 500000, + unit: "USD", + }; + } + if (response.error) { + return { + isValid: false, + invalidMessage: response.error.message, + }; + } + }, +})`, +}; + +const UsageScriptModal: React.FC = ({ + provider, + appType, + onClose, + onSave, + onNotify, +}) => { + const [script, setScript] = useState(() => { + return ( + provider.meta?.usage_script || { + enabled: false, + language: "javascript", + code: PRESET_TEMPLATES["通用模板"], + timeout: 10, + } + ); + }); + + const [testing, setTesting] = useState(false); + + const handleSave = () => { + // 验证脚本格式 + if (script.enabled && !script.code.trim()) { + onNotify?.("脚本配置不能为空", "error"); + return; + } + + // 基本的 JS 语法检查(检查是否包含 return 语句) + if (script.enabled && !script.code.includes("return")) { + onNotify?.("脚本必须包含 return 语句", "error", 5000); + return; + } + + onSave(script); + onClose(); + onNotify?.("用量查询配置已保存", "success", 2000); + }; + + const handleTest = async () => { + setTesting(true); + try { + const result = await window.api.queryProviderUsage( + provider.id, + appType + ); + if (result.success && result.data && result.data.length > 0) { + // 显示所有套餐数据 + const summary = result.data + .map((plan) => { + const planInfo = plan.planName ? `[${plan.planName}]` : ""; + return `${planInfo} 剩余: ${plan.remaining} ${plan.unit}`; + }) + .join(", "); + onNotify?.(`测试成功!${summary}`, "success", 3000); + } else { + onNotify?.(`测试失败: ${result.error || "无数据返回"}`, "error", 5000); + } + } catch (error: any) { + onNotify?.(`测试失败: ${error?.message || "未知错误"}`, "error", 5000); + } finally { + setTesting(false); + } + }; + + const handleFormat = async () => { + try { + const formatted = await prettier.format(script.code, { + parser: "babel", + plugins: [parserBabel as any, pluginEstree as any], + semi: true, + singleQuote: false, + tabWidth: 2, + printWidth: 80, + }); + setScript({ ...script, code: formatted.trim() }); + onNotify?.("格式化成功", "success", 1000); + } catch (error: any) { + onNotify?.(`格式化失败: ${error?.message || "语法错误"}`, "error", 3000); + } + }; + + const handleUsePreset = (presetName: string) => { + const preset = PRESET_TEMPLATES[presetName]; + if (preset) { + setScript({ ...script, code: preset }); + } + }; + + return ( +
+
+ {/* Header */} +
+

+ 配置用量查询 - {provider.name} +

+ +
+ + {/* Content */} +
+ {/* 启用开关 */} + + + {script.enabled && ( + <> + {/* 预设模板选择 */} +
+ +
+ {Object.keys(PRESET_TEMPLATES).map((name) => ( + + ))} +
+
+ + {/* 脚本编辑器 */} +
+ + setScript({ ...script, code })} + height="300px" + language="javascript" + /> +

+ 支持变量: {"{{apiKey}}"},{" "} + {"{{baseUrl}}"} | extractor 函数接收 API 响应的 JSON 对象 +

+
+ + {/* 配置选项 */} +
+ +
+ + {/* 脚本说明 */} +
+

脚本编写说明:

+
+
+ 配置格式: +
+{`({
+  request: {
+    url: "{{baseUrl}}/api/usage",
+    method: "POST",
+    headers: {
+      "Authorization": "Bearer {{apiKey}}",
+      "User-Agent": "cc-switch/1.0"
+    },
+    body: JSON.stringify({ key: "value" })  // 可选
+  },
+  extractor: function(response) {
+    // response 是 API 返回的 JSON 数据
+    return {
+      isValid: !response.error,
+      remaining: response.balance,
+      unit: "USD"
+    };
+  }
+})`}
+                    
+
+ +
+ extractor 返回格式(所有字段均为可选): +
    +
  • isValid: 布尔值,套餐是否有效
  • +
  • invalidMessage: 字符串,失效原因说明(当 isValid 为 false 时显示)
  • +
  • remaining: 数字,剩余额度
  • +
  • unit: 字符串,单位(如 "USD")
  • +
  • planName: 字符串,套餐名称
  • +
  • total: 数字,总额度
  • +
  • used: 数字,已用额度
  • +
  • extra: 字符串,扩展字段,可自由补充需要展示的文本
  • +
+
+ +
+ 💡 提示: +
    +
  • • 变量 {"{{apiKey}}"}{"{{baseUrl}}"} 会自动替换
  • +
  • • extractor 函数在沙箱环境中执行,支持 ES2020+ 语法
  • +
  • • 整个配置必须用 () 包裹,形成对象字面量表达式
  • +
+
+
+
+ + )} +
+ + {/* Footer */} +
+
+ + +
+ +
+ + +
+
+
+
+ ); +}; + +export default UsageScriptModal; diff --git a/src/lib/tauri-api.ts b/src/lib/tauri-api.ts index 3df7cdd..7590147 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -287,6 +287,24 @@ export const tauriAPI = { } }, + // 查询供应商用量 + queryProviderUsage: async ( + providerId: string, + app: AppType + ): Promise => { + try { + return await invoke("query_provider_usage", { + provider_id: providerId, + providerId: providerId, + app_type: app, + app: app, + appType: app, + }); + } catch (error) { + throw new Error(`查询用量失败: ${String(error)}`); + } + }, + // Claude MCP:获取状态(用户级 ~/.claude.json) getClaudeMcpStatus: async (): Promise => { try { diff --git a/src/types.ts b/src/types.ts index 4cd54d2..ee0150d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,10 +29,39 @@ export interface CustomEndpoint { lastUsed?: number; } +// 用量查询脚本配置 +export interface UsageScript { + enabled: boolean; // 是否启用用量查询 + language: "javascript"; // 脚本语言 + code: string; // 脚本代码(JSON 格式配置) + timeout?: number; // 超时时间(秒,默认 10) +} + +// 单个套餐用量数据 +export interface UsageData { + planName?: string; // 套餐名称(可选) + extra?: string; // 扩展字段,可自由补充需要展示的文本(可选) + isValid?: boolean; // 套餐是否有效(可选) + invalidMessage?: string; // 失效原因说明(可选,当 isValid 为 false 时显示) + total?: number; // 总额度(可选) + used?: number; // 已用额度(可选) + remaining?: number; // 剩余额度(可选) + unit?: string; // 单位(可选) +} + +// 用量查询结果(支持多套餐) +export interface UsageResult { + success: boolean; + data?: UsageData[]; // 改为数组,支持返回多个套餐 + error?: string; +} + // 供应商元数据(字段名与后端一致,保持 snake_case) export interface ProviderMeta { // 自定义端点:以 URL 为键,值为端点信息 custom_endpoints?: Record; + // 用量查询脚本配置 + usage_script?: UsageScript; } // 应用设置类型(用于 SettingsModal 与 Tauri API) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 17eed09..4a0356c 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -69,6 +69,11 @@ declare global { official: boolean; }) => Promise; isClaudePluginApplied: () => Promise; + // 查询供应商用量 + queryProviderUsage: ( + providerId: string, + app: AppType + ) => Promise; // Claude MCP getClaudeMcpStatus: () => Promise; readClaudeMcpConfig: () => Promise;