feat: add provider usage query with JavaScript scripting support (#101)
* feat: add provider usage query functionality
- Updated `Cargo.toml` to include `regex` and `rquickjs` dependencies for usage script execution.
- Implemented `query_provider_usage` command in `commands.rs` to handle usage queries.
- Created `UsageScript` and `UsageData` structs in `provider.rs` for managing usage script configurations and results.
- Added `execute_usage_script` function in `usage_script.rs` to run user-defined scripts for querying usage.
- Enhanced `ProviderList` component to include a button for configuring usage scripts and a modal for editing scripts.
- Introduced `UsageFooter` component to display usage information and status.
- Added `UsageScriptModal` for editing and testing usage scripts with preset templates.
- Updated Tauri API to support querying provider usage.
- Modified types in `types.ts` to include structures for usage scripts and results.
* feat(usage): support multi-plan usage display for providers
- 【Feature】
- Update `UsageResult` to support an array of `UsageData` for displaying multiple usage plans per provider.
- Refactor `query_provider_usage` command to parse both single `UsageData` objects (for backward compatibility) and arrays of `UsageData`.
- Enhance `usage_script` validation to accept either a single usage object or an array of usage objects.
- 【Frontend】
- Redesign `UsageFooter` to iterate and display details for all available usage plans, introducing `UsagePlanItem` for individual plan rendering.
- Improve usage display with color-coded remaining balance and clear plan information.
- Update `UsageScriptModal` test notification to summarize all returned plans.
- Remove redundant `isCurrent` prop from `UsageFooter` in `ProviderList`.
- 【Build】
- Change frontend development server port from `3000` to `3005` in `tauri.conf.json` and `vite.config.mts`.
* feat(usage): enhance query flexibility and display
- 【`src/types.ts`, `src-tauri/src/provider.rs`】Make `UsageData` fields optional and introduce `extra` and `invalidMessage` for more flexible reporting.
- `expiresAt` replaced by generic `extra` field.
- `isValid`, `remaining`, `unit` are now optional.
- Added `invalidMessage` to provide specific reasons for invalid status.
- 【`src-tauri/src/usage_script.rs`】Relax usage script result validation to accommodate optional fields in `UsageData`.
- 【`src/components/UsageFooter.tsx`】Update UI to display `extra` field and `invalidMessage`, and conditionally render `remaining` and `unit` based on availability.
- 【`src/components/UsageScriptModal.tsx`】
- Add a new `NewAPI` preset template demonstrating advanced extractor logic for complex API responses.
- Update script instructions to reflect optional fields and new variable syntax (`{{apiKey}}`).
- Remove old "DeepSeek" and "OpenAI" templates.
- Remove basic syntax check for `return` statement.
- 【`.vscode/settings.json`】Add `dish-ai-commit.base.language` setting.
- 【`src-tauri/src/commands.rs`】Adjust usage logging to handle optional `remaining` and `unit` fields.
* chore(config): remove VS Code settings from version control
- delete .vscode/settings.json to remove editor-specific configurations
- add /.vscode to .gitignore to prevent tracking of local VS Code settings
- ensure personalized editor preferences are not committed to the repository
* fix(provider): preserve usage script during provider update
- When updating a provider, the `usage_script` configuration within `ProviderMeta` was not explicitly merged.
- This could lead to the accidental loss of `usage_script` settings if the incoming `provider` object in the update request did not contain this field.
- Ensure `usage_script` is cloned from the existing provider's meta when merging `ProviderMeta` during an update.
* refactor(provider): enforce base_url for usage scripts and update dev ports
- 【Backend】
- `src-tauri/src/commands.rs`: Made `ANTHROPIC_BASE_URL` a required field for Claude providers and `base_url` a required field in `config.toml` for Codex providers when extracting credentials for usage script execution. This improves error handling by explicitly failing if these critical URLs are missing or malformed.
- 【Frontend】
- `src/App.tsx`, `src/components/ProviderList.tsx`: Passed `appType` prop to `ProviderList` component to ensure `updateProvider` calls within `handleSaveUsageScript` correctly identify the application type.
- 【Config】
- `src-tauri/tauri.conf.json`, `vite.config.mts`: Updated development server ports from `3005` to `3000` to standardize local development environment.
* refactor(usage): improve usage data fetching logic
- Prevent redundant API calls by tracking last fetched parameters in `useEffect`.
- Avoid concurrent API requests by adding a guard in `fetchUsage`.
- Clear usage data and last fetch parameters when usage query is disabled.
- Add `queryProviderUsage` API declaration to `window.api` interface.
* fix(usage-script): ensure usage script updates and improve reactivity
- correctly update `usage_script` from new provider meta during updates
- replace full page reload with targeted provider data refresh after saving usage script settings
- trigger usage data fetch or clear when `usageEnabled` status changes in `UsageFooter`
- reduce logging verbosity for usage script execution in backend commands and script execution
* style(usage-footer): adjust usage plan item layout
- Decrease width of extra field column from 35% to 30%
- Increase width of usage information column from 40% to 45%
- Improve visual balance and readability of usage plan items
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ release/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
/.claude
|
/.claude
|
||||||
|
/.vscode
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/lint": "^6.8.5",
|
"@codemirror/lint": "^6.8.5",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
|
|||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@codemirror/lang-javascript':
|
||||||
|
specifier: ^6.2.4
|
||||||
|
version: 6.2.4
|
||||||
'@codemirror/lang-json':
|
'@codemirror/lang-json':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.2
|
version: 6.0.2
|
||||||
@@ -190,6 +193,9 @@ packages:
|
|||||||
'@codemirror/commands@6.8.1':
|
'@codemirror/commands@6.8.1':
|
||||||
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
|
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
|
||||||
|
|
||||||
|
'@codemirror/lang-javascript@6.2.4':
|
||||||
|
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
|
||||||
|
|
||||||
'@codemirror/lang-json@6.0.2':
|
'@codemirror/lang-json@6.0.2':
|
||||||
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
|
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
|
||||||
|
|
||||||
@@ -378,6 +384,9 @@ packages:
|
|||||||
'@lezer/highlight@1.2.1':
|
'@lezer/highlight@1.2.1':
|
||||||
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
|
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
|
||||||
|
|
||||||
|
'@lezer/javascript@1.5.4':
|
||||||
|
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
|
||||||
|
|
||||||
'@lezer/json@1.0.3':
|
'@lezer/json@1.0.3':
|
||||||
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
|
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
|
||||||
|
|
||||||
@@ -1190,6 +1199,16 @@ snapshots:
|
|||||||
'@codemirror/view': 6.38.2
|
'@codemirror/view': 6.38.2
|
||||||
'@lezer/common': 1.2.3
|
'@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':
|
'@codemirror/lang-json@6.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/language': 6.11.3
|
'@codemirror/language': 6.11.3
|
||||||
@@ -1334,6 +1353,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.2.3
|
'@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':
|
'@lezer/json@1.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.2.3
|
'@lezer/common': 1.2.3
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
|
packages: []
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@tailwindcss/oxide'
|
- '@tailwindcss/oxide'
|
||||||
|
|||||||
29
src-tauri/Cargo.lock
generated
29
src-tauri/Cargo.lock
generated
@@ -571,7 +571,9 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit 0.2.2",
|
"objc2-app-kit 0.2.2",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rquickjs",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -3590,6 +3592,33 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"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]]
|
[[package]]
|
||||||
name = "rust_decimal"
|
name = "rust_decimal"
|
||||||
version = "1.38.0"
|
version = "1.38.0"
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ tauri-plugin-updater = "2"
|
|||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
toml = "0.8"
|
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"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||||
futures = "0.3"
|
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]
|
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ pub async fn update_provider(
|
|||||||
}
|
}
|
||||||
updated.meta = Some(crate::provider::ProviderMeta {
|
updated.meta = Some(crate::provider::ProviderMeta {
|
||||||
custom_endpoints: merged_map,
|
custom_endpoints: merged_map,
|
||||||
|
usage_script: new_meta.usage_script.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 旧 meta 不存在:使用入参(可能为 None)
|
// 旧 meta 不存在:使用入参(可能为 None)
|
||||||
@@ -798,6 +799,172 @@ pub async fn validate_mcp_command(cmd: String) -> Result<bool, String> {
|
|||||||
claude_mcp::validate_command_in_path(&cmd)
|
claude_mcp::validate_command_in_path(&cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 用量查询命令
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/// 查询供应商用量
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn query_provider_usage(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
provider_id: Option<String>,
|
||||||
|
providerId: Option<String>,
|
||||||
|
app_type: Option<AppType>,
|
||||||
|
app: Option<String>,
|
||||||
|
appType: Option<String>,
|
||||||
|
) -> Result<crate::provider::UsageResult, String> {
|
||||||
|
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<UsageData> = 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 配置命令
|
// 新:集中以 config.json 为 SSOT 的 MCP 配置命令
|
||||||
// =====================
|
// =====================
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mod migration;
|
|||||||
mod provider;
|
mod provider;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod speedtest;
|
mod speedtest;
|
||||||
|
mod usage_script;
|
||||||
mod store;
|
mod store;
|
||||||
|
|
||||||
use store::AppState;
|
use store::AppState;
|
||||||
@@ -429,6 +430,8 @@ pub fn run() {
|
|||||||
commands::upsert_claude_mcp_server,
|
commands::upsert_claude_mcp_server,
|
||||||
commands::delete_claude_mcp_server,
|
commands::delete_claude_mcp_server,
|
||||||
commands::validate_mcp_command,
|
commands::validate_mcp_command,
|
||||||
|
// usage query
|
||||||
|
commands::query_provider_usage,
|
||||||
// New MCP via config.json (SSOT)
|
// New MCP via config.json (SSOT)
|
||||||
commands::get_mcp_config,
|
commands::get_mcp_config,
|
||||||
commands::upsert_mcp_server_in_config,
|
commands::upsert_mcp_server_in_config,
|
||||||
|
|||||||
@@ -51,12 +51,59 @@ pub struct ProviderManager {
|
|||||||
pub current: String,
|
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<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用量数据
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UsageData {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "planName")]
|
||||||
|
pub plan_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub extra: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "isValid")]
|
||||||
|
pub is_valid: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "invalidMessage")]
|
||||||
|
pub invalid_message: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub total: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub used: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub remaining: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub unit: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用量查询结果(支持多套餐)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UsageResult {
|
||||||
|
pub success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub data: Option<Vec<UsageData>>, // 支持返回多个套餐
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 供应商元数据
|
/// 供应商元数据
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProviderMeta {
|
pub struct ProviderMeta {
|
||||||
/// 自定义端点列表(按 URL 去重存储)
|
/// 自定义端点列表(按 URL 去重存储)
|
||||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
|
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
|
||||||
|
/// 用量查询脚本配置
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_script: Option<UsageScript>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderManager {
|
impl ProviderManager {
|
||||||
|
|||||||
207
src-tauri/src/usage_script.rs
Normal file
207
src-tauri/src/usage_script.rs
Normal file
@@ -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<Value, String> {
|
||||||
|
// 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<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送 HTTP 请求
|
||||||
|
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, String> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -354,7 +354,9 @@ function App() {
|
|||||||
onSwitch={handleSwitchProvider}
|
onSwitch={handleSwitchProvider}
|
||||||
onDelete={handleDeleteProvider}
|
onDelete={handleDeleteProvider}
|
||||||
onEdit={setEditingProviderId}
|
onEdit={setEditingProviderId}
|
||||||
|
appType={activeApp}
|
||||||
onNotify={showNotification}
|
onNotify={showNotification}
|
||||||
|
onProvidersUpdated={loadProviders}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useRef, useEffect, useMemo } from "react";
|
import React, { useRef, useEffect, useMemo } from "react";
|
||||||
import { EditorView, basicSetup } from "codemirror";
|
import { EditorView, basicSetup } from "codemirror";
|
||||||
import { json } from "@codemirror/lang-json";
|
import { json } from "@codemirror/lang-json";
|
||||||
|
import { javascript } from "@codemirror/lang-javascript";
|
||||||
import { oneDark } from "@codemirror/theme-one-dark";
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
import { EditorState } from "@codemirror/state";
|
import { EditorState } from "@codemirror/state";
|
||||||
import { placeholder } from "@codemirror/view";
|
import { placeholder } from "@codemirror/view";
|
||||||
@@ -14,6 +15,8 @@ interface JsonEditorProps {
|
|||||||
darkMode?: boolean;
|
darkMode?: boolean;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
showValidation?: boolean;
|
showValidation?: boolean;
|
||||||
|
language?: "json" | "javascript";
|
||||||
|
height?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JsonEditor: React.FC<JsonEditorProps> = ({
|
const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||||
@@ -23,6 +26,8 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
darkMode = false,
|
darkMode = false,
|
||||||
rows = 12,
|
rows = 12,
|
||||||
showValidation = true,
|
showValidation = true,
|
||||||
|
language = "json",
|
||||||
|
height,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -33,7 +38,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
() =>
|
() =>
|
||||||
linter((view) => {
|
linter((view) => {
|
||||||
const diagnostics: Diagnostic[] = [];
|
const diagnostics: Diagnostic[] = [];
|
||||||
if (!showValidation) return diagnostics;
|
if (!showValidation || language !== "json") return diagnostics;
|
||||||
|
|
||||||
const doc = view.state.doc.toString();
|
const doc = view.state.doc.toString();
|
||||||
if (!doc.trim()) return diagnostics;
|
if (!doc.trim()) return diagnostics;
|
||||||
@@ -65,16 +70,16 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
|
|
||||||
return diagnostics;
|
return diagnostics;
|
||||||
}),
|
}),
|
||||||
[showValidation, t],
|
[showValidation, language, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
// 创建编辑器扩展
|
// 创建编辑器扩展
|
||||||
const minHeightPx = Math.max(1, rows) * 18; // 降低最小高度以减少抖动
|
const minHeightPx = height ? undefined : Math.max(1, rows) * 18;
|
||||||
const sizingTheme = EditorView.theme({
|
const sizingTheme = EditorView.theme({
|
||||||
"&": { minHeight: `${minHeightPx}px` },
|
"&": height ? { height } : { minHeight: `${minHeightPx}px` },
|
||||||
".cm-scroller": { overflow: "auto" },
|
".cm-scroller": { overflow: "auto" },
|
||||||
".cm-content": {
|
".cm-content": {
|
||||||
fontFamily:
|
fontFamily:
|
||||||
@@ -85,7 +90,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
|
|
||||||
const extensions = [
|
const extensions = [
|
||||||
basicSetup,
|
basicSetup,
|
||||||
json(),
|
language === "javascript" ? javascript() : json(),
|
||||||
placeholder(placeholderText || ""),
|
placeholder(placeholderText || ""),
|
||||||
sizingTheme,
|
sizingTheme,
|
||||||
jsonLinter,
|
jsonLinter,
|
||||||
@@ -121,7 +126,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
view.destroy();
|
view.destroy();
|
||||||
viewRef.current = null;
|
viewRef.current = null;
|
||||||
};
|
};
|
||||||
}, [darkMode, rows, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
}, [darkMode, rows, height, language, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
||||||
|
|
||||||
// 当 value 从外部改变时更新编辑器内容
|
// 当 value 从外部改变时更新编辑器内容
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Provider } from "../types";
|
import { Provider, UsageScript } from "../types";
|
||||||
import { Play, Edit3, Trash2, CheckCircle2, Users, Check } from "lucide-react";
|
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 { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
|
||||||
|
import UsageFooter from "./UsageFooter";
|
||||||
|
import UsageScriptModal from "./UsageScriptModal";
|
||||||
// 不再在列表中显示分类徽章,避免造成困惑
|
// 不再在列表中显示分类徽章,避免造成困惑
|
||||||
|
|
||||||
interface ProviderListProps {
|
interface ProviderListProps {
|
||||||
@@ -11,11 +14,13 @@ interface ProviderListProps {
|
|||||||
onSwitch: (id: string) => void;
|
onSwitch: (id: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
|
appType: AppType;
|
||||||
onNotify?: (
|
onNotify?: (
|
||||||
message: string,
|
message: string,
|
||||||
type: "success" | "error",
|
type: "success" | "error",
|
||||||
duration?: number,
|
duration?: number,
|
||||||
) => void;
|
) => void;
|
||||||
|
onProvidersUpdated?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProviderList: React.FC<ProviderListProps> = ({
|
const ProviderList: React.FC<ProviderListProps> = ({
|
||||||
@@ -24,9 +29,13 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
onSwitch,
|
onSwitch,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
appType,
|
||||||
onNotify,
|
onNotify,
|
||||||
|
onProvidersUpdated,
|
||||||
}) => {
|
}) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const [usageModalProviderId, setUsageModalProviderId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
||||||
const getApiUrl = (provider: Provider): string => {
|
const getApiUrl = (provider: Provider): string => {
|
||||||
try {
|
try {
|
||||||
@@ -62,6 +71,29 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
|
|
||||||
// 列表页不再提供 Claude 插件按钮,统一在“设置”中控制
|
// 列表页不再提供 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) => {
|
const sortedProviders = Object.values(providers).sort((a, b) => {
|
||||||
// 按添加时间排序
|
// 按添加时间排序
|
||||||
@@ -177,6 +209,15 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
<Edit3 size={16} />
|
<Edit3 size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 新增:用量配置按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setUsageModalProviderId(provider.id)}
|
||||||
|
className={buttonStyles.icon}
|
||||||
|
title="配置用量查询"
|
||||||
|
>
|
||||||
|
<BarChart3 size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(provider.id)}
|
onClick={() => onDelete(provider.id)}
|
||||||
disabled={isCurrent}
|
disabled={isCurrent}
|
||||||
@@ -192,11 +233,31 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 用量信息 Footer */}
|
||||||
|
<UsageFooter
|
||||||
|
providerId={provider.id}
|
||||||
|
appType={appType!}
|
||||||
|
usageEnabled={provider.meta?.usage_script?.enabled || false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 用量配置模态框 */}
|
||||||
|
{usageModalProviderId && providers[usageModalProviderId] && (
|
||||||
|
<UsageScriptModal
|
||||||
|
provider={providers[usageModalProviderId]}
|
||||||
|
appType={appType!}
|
||||||
|
onClose={() => setUsageModalProviderId(null)}
|
||||||
|
onSave={(script) =>
|
||||||
|
handleSaveUsageScript(usageModalProviderId, script)
|
||||||
|
}
|
||||||
|
onNotify={onNotify}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
210
src/components/UsageFooter.tsx
Normal file
210
src/components/UsageFooter.tsx
Normal file
@@ -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<UsageFooterProps> = ({
|
||||||
|
providerId,
|
||||||
|
appType,
|
||||||
|
usageEnabled,
|
||||||
|
}) => {
|
||||||
|
const [usage, setUsage] = useState<UsageResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 记录上次请求的关键参数,防止重复请求
|
||||||
|
const lastFetchParamsRef = useRef<string>('');
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2 text-red-500 dark:text-red-400">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
<span>{usage.error || "查询失败"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 刷新按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => fetchUsage()}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
|
||||||
|
title="刷新用量"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageDataList = usage.data || [];
|
||||||
|
|
||||||
|
// 无数据时不显示
|
||||||
|
if (usageDataList.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
{/* 标题行:包含刷新按钮 */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
套餐用量
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchUsage()}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||||
|
title="刷新用量"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 套餐列表 */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{usageDataList.map((usageData, index) => (
|
||||||
|
<UsagePlanItem key={index} data={usageData} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 单个套餐数据展示组件
|
||||||
|
const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
|
||||||
|
const { planName, extra, isValid, invalidMessage, total, used, remaining, unit } = data;
|
||||||
|
|
||||||
|
// 判断套餐是否失效(isValid 为 false 或未定义时视为有效)
|
||||||
|
const isExpired = isValid === false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 标题部分:25% */}
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 min-w-0" style={{ width: "25%" }}>
|
||||||
|
{planName ? (
|
||||||
|
<span
|
||||||
|
className={`font-medium truncate block ${isExpired ? "text-red-500 dark:text-red-400" : ""}`}
|
||||||
|
title={planName}
|
||||||
|
>
|
||||||
|
💰 {planName}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="opacity-50">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 扩展字段:30% */}
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 min-w-0 flex items-center gap-2" style={{ width: "30%" }}>
|
||||||
|
{extra && (
|
||||||
|
<span
|
||||||
|
className={`truncate ${isExpired ? "text-red-500 dark:text-red-400" : ""}`}
|
||||||
|
title={extra}
|
||||||
|
>
|
||||||
|
{extra}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isExpired && (
|
||||||
|
<span className="text-red-500 dark:text-red-400 font-medium text-[10px] px-1.5 py-0.5 bg-red-50 dark:bg-red-900/20 rounded flex-shrink-0">
|
||||||
|
{invalidMessage || "已失效"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用量信息:45% */}
|
||||||
|
<div className="flex items-center justify-end gap-2 text-xs flex-shrink-0" style={{ width: "45%" }}>
|
||||||
|
{/* 总额度 */}
|
||||||
|
{total !== undefined && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">总:</span>
|
||||||
|
<span className="tabular-nums text-gray-600 dark:text-gray-400">
|
||||||
|
{total === -1 ? "∞" : total.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 dark:text-gray-600">|</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 已用额度 */}
|
||||||
|
{used !== undefined && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">使用:</span>
|
||||||
|
<span className="tabular-nums text-gray-600 dark:text-gray-400">
|
||||||
|
{used.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 dark:text-gray-600">|</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 剩余额度 - 突出显示 */}
|
||||||
|
{remaining !== undefined && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">剩余:</span>
|
||||||
|
<span
|
||||||
|
className={`font-semibold tabular-nums ${
|
||||||
|
isExpired
|
||||||
|
? "text-red-500 dark:text-red-400"
|
||||||
|
: remaining < (total || remaining) * 0.1
|
||||||
|
? "text-orange-500 dark:text-orange-400"
|
||||||
|
: "text-green-600 dark:text-green-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{remaining.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unit && <span className="text-gray-500 dark:text-gray-400">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default UsageFooter;
|
||||||
355
src/components/UsageScriptModal.tsx
Normal file
355
src/components/UsageScriptModal.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
通用模板: `({
|
||||||
|
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<UsageScriptModalProps> = ({
|
||||||
|
provider,
|
||||||
|
appType,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onNotify,
|
||||||
|
}) => {
|
||||||
|
const [script, setScript] = useState<UsageScript>(() => {
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
配置用量查询 - {provider.name}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||||
|
{/* 启用开关 */}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={script.enabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
setScript({ ...script, enabled: e.target.checked })
|
||||||
|
}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
启用用量查询
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{script.enabled && (
|
||||||
|
<>
|
||||||
|
{/* 预设模板选择 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
|
||||||
|
预设模板
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{Object.keys(PRESET_TEMPLATES).map((name) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => handleUsePreset(name)}
|
||||||
|
className="px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 脚本编辑器 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
|
||||||
|
查询脚本(JavaScript)
|
||||||
|
</label>
|
||||||
|
<JsonEditor
|
||||||
|
value={script.code}
|
||||||
|
onChange={(code) => setScript({ ...script, code })}
|
||||||
|
height="300px"
|
||||||
|
language="javascript"
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
支持变量: <code>{"{{apiKey}}"}</code>,{" "}
|
||||||
|
<code>{"{{baseUrl}}"}</code> | extractor 函数接收 API 响应的 JSON 对象
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 配置选项 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
超时时间(秒)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="2"
|
||||||
|
max="30"
|
||||||
|
value={script.timeout || 10}
|
||||||
|
onChange={(e) =>
|
||||||
|
setScript({ ...script, timeout: parseInt(e.target.value) })
|
||||||
|
}
|
||||||
|
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 脚本说明 */}
|
||||||
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<h4 className="font-medium mb-2">脚本编写说明:</h4>
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<strong>配置格式:</strong>
|
||||||
|
<pre className="mt-1 p-2 bg-white/50 dark:bg-black/20 rounded text-[10px] overflow-x-auto">
|
||||||
|
{`({
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<strong>extractor 返回格式(所有字段均为可选):</strong>
|
||||||
|
<ul className="mt-1 space-y-0.5 ml-2">
|
||||||
|
<li>• <code>isValid</code>: 布尔值,套餐是否有效</li>
|
||||||
|
<li>• <code>invalidMessage</code>: 字符串,失效原因说明(当 isValid 为 false 时显示)</li>
|
||||||
|
<li>• <code>remaining</code>: 数字,剩余额度</li>
|
||||||
|
<li>• <code>unit</code>: 字符串,单位(如 "USD")</li>
|
||||||
|
<li>• <code>planName</code>: 字符串,套餐名称</li>
|
||||||
|
<li>• <code>total</code>: 数字,总额度</li>
|
||||||
|
<li>• <code>used</code>: 数字,已用额度</li>
|
||||||
|
<li>• <code>extra</code>: 字符串,扩展字段,可自由补充需要展示的文本</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
<strong>💡 提示:</strong>
|
||||||
|
<ul className="mt-1 space-y-0.5 ml-2">
|
||||||
|
<li>• 变量 <code>{"{{apiKey}}"}</code> 和 <code>{"{{baseUrl}}"}</code> 会自动替换</li>
|
||||||
|
<li>• extractor 函数在沙箱环境中执行,支持 ES2020+ 语法</li>
|
||||||
|
<li>• 整个配置必须用 <code>()</code> 包裹,形成对象字面量表达式</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={!script.enabled || testing}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Play size={14} />
|
||||||
|
{testing ? "测试中..." : "测试脚本"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleFormat}
|
||||||
|
disabled={!script.enabled}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="格式化代码 (Prettier)"
|
||||||
|
>
|
||||||
|
<Wand2 size={14} />
|
||||||
|
格式化
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
保存配置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsageScriptModal;
|
||||||
@@ -287,6 +287,24 @@ export const tauriAPI = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 查询供应商用量
|
||||||
|
queryProviderUsage: async (
|
||||||
|
providerId: string,
|
||||||
|
app: AppType
|
||||||
|
): Promise<import("../types").UsageResult> => {
|
||||||
|
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)
|
// Claude MCP:获取状态(用户级 ~/.claude.json)
|
||||||
getClaudeMcpStatus: async (): Promise<McpStatus> => {
|
getClaudeMcpStatus: async (): Promise<McpStatus> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
29
src/types.ts
29
src/types.ts
@@ -29,10 +29,39 @@ export interface CustomEndpoint {
|
|||||||
lastUsed?: number;
|
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)
|
// 供应商元数据(字段名与后端一致,保持 snake_case)
|
||||||
export interface ProviderMeta {
|
export interface ProviderMeta {
|
||||||
// 自定义端点:以 URL 为键,值为端点信息
|
// 自定义端点:以 URL 为键,值为端点信息
|
||||||
custom_endpoints?: Record<string, CustomEndpoint>;
|
custom_endpoints?: Record<string, CustomEndpoint>;
|
||||||
|
// 用量查询脚本配置
|
||||||
|
usage_script?: UsageScript;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用设置类型(用于 SettingsModal 与 Tauri API)
|
// 应用设置类型(用于 SettingsModal 与 Tauri API)
|
||||||
|
|||||||
5
src/vite-env.d.ts
vendored
5
src/vite-env.d.ts
vendored
@@ -69,6 +69,11 @@ declare global {
|
|||||||
official: boolean;
|
official: boolean;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
isClaudePluginApplied: () => Promise<boolean>;
|
isClaudePluginApplied: () => Promise<boolean>;
|
||||||
|
// 查询供应商用量
|
||||||
|
queryProviderUsage: (
|
||||||
|
providerId: string,
|
||||||
|
app: AppType
|
||||||
|
) => Promise<import("./types").UsageResult>;
|
||||||
// Claude MCP
|
// Claude MCP
|
||||||
getClaudeMcpStatus: () => Promise<McpStatus>;
|
getClaudeMcpStatus: () => Promise<McpStatus>;
|
||||||
readClaudeMcpConfig: () => Promise<string | null>;
|
readClaudeMcpConfig: () => Promise<string | null>;
|
||||||
|
|||||||
Reference in New Issue
Block a user