mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b5b3efad4 | |||
| 07d022ba9f |
@@ -133,6 +133,7 @@ pub async fn testUsageScript(
|
|||||||
#[allow(non_snake_case)] baseUrl: Option<String>,
|
#[allow(non_snake_case)] baseUrl: Option<String>,
|
||||||
#[allow(non_snake_case)] accessToken: Option<String>,
|
#[allow(non_snake_case)] accessToken: Option<String>,
|
||||||
#[allow(non_snake_case)] userId: Option<String>,
|
#[allow(non_snake_case)] userId: Option<String>,
|
||||||
|
#[allow(non_snake_case)] templateType: Option<String>,
|
||||||
) -> Result<crate::provider::UsageResult, String> {
|
) -> Result<crate::provider::UsageResult, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
ProviderService::test_usage_script(
|
ProviderService::test_usage_script(
|
||||||
@@ -145,6 +146,7 @@ pub async fn testUsageScript(
|
|||||||
baseUrl.as_deref(),
|
baseUrl.as_deref(),
|
||||||
accessToken.as_deref(),
|
accessToken.as_deref(),
|
||||||
userId.as_deref(),
|
userId.as_deref(),
|
||||||
|
templateType.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
|||||||
}),
|
}),
|
||||||
access_token: request.usage_access_token.clone(),
|
access_token: request.usage_access_token.clone(),
|
||||||
user_id: request.usage_user_id.clone(),
|
user_id: request.usage_user_id.clone(),
|
||||||
|
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||||
auto_query_interval: request.usage_auto_interval,
|
auto_query_interval: request.usage_auto_interval,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ pub struct UsageScript {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "userId")]
|
#[serde(rename = "userId")]
|
||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
|
/// 模板类型(用于后端判断验证规则)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "templateType")]
|
||||||
|
pub template_type: Option<String>,
|
||||||
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "autoQueryInterval")]
|
#[serde(rename = "autoQueryInterval")]
|
||||||
|
|||||||
@@ -615,6 +615,7 @@ impl ProviderService {
|
|||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
usage::test_usage_script(
|
usage::test_usage_script(
|
||||||
state,
|
state,
|
||||||
@@ -626,6 +627,7 @@ impl ProviderService {
|
|||||||
base_url,
|
base_url,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
timeout: u64,
|
timeout: u64,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
match usage_script::execute_usage_script(
|
match usage_script::execute_usage_script(
|
||||||
script_code,
|
script_code,
|
||||||
@@ -25,6 +26,7 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -113,7 +115,7 @@ pub async fn query_usage(
|
|||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
|
let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = {
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
let provider = providers.get(provider_id).ok_or_else(|| {
|
let provider = providers.get(provider_id).ok_or_else(|| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -164,6 +166,7 @@ pub async fn query_usage(
|
|||||||
base_url,
|
base_url,
|
||||||
usage_script.access_token.clone(),
|
usage_script.access_token.clone(),
|
||||||
usage_script.user_id.clone(),
|
usage_script.user_id.clone(),
|
||||||
|
usage_script.template_type.clone(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +177,7 @@ pub async fn query_usage(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token.as_deref(),
|
access_token.as_deref(),
|
||||||
user_id.as_deref(),
|
user_id.as_deref(),
|
||||||
|
template_type.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -190,6 +194,7 @@ pub async fn test_usage_script(
|
|||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
// Use provided credential parameters directly for testing
|
// Use provided credential parameters directly for testing
|
||||||
execute_and_format_usage_result(
|
execute_and_format_usage_result(
|
||||||
@@ -199,6 +204,7 @@ pub async fn test_usage_script(
|
|||||||
timeout,
|
timeout,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
|
template_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,21 @@ pub async fn execute_usage_script(
|
|||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
) -> Result<Value, AppError> {
|
) -> Result<Value, AppError> {
|
||||||
|
// 检测是否为自定义模板模式
|
||||||
|
// 优先使用前端传递的 template_type
|
||||||
|
let is_custom_template = template_type.map(|t| t == "custom").unwrap_or(false);
|
||||||
|
|
||||||
// 1. 替换模板变量,避免泄露敏感信息
|
// 1. 替换模板变量,避免泄露敏感信息
|
||||||
let script_with_vars =
|
let script_with_vars =
|
||||||
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
||||||
|
|
||||||
// 2. 验证 base_url 的安全性
|
// 2. 验证 base_url 的安全性(仅当提供了 base_url 时)
|
||||||
validate_base_url(base_url)?;
|
// 自定义模板模式下,用户可能不使用模板变量,而是直接在脚本中写完整 URL
|
||||||
|
if !base_url.is_empty() {
|
||||||
|
validate_base_url(base_url)?;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
||||||
let request_config = {
|
let request_config = {
|
||||||
@@ -97,7 +105,8 @@ pub async fn execute_usage_script(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
||||||
validate_request_url(&request.url, base_url)?;
|
// 如果提供了 base_url,则验证同源;否则只做基本安全检查
|
||||||
|
validate_request_url(&request.url, base_url, is_custom_template)?;
|
||||||
|
|
||||||
// 6. 发送 HTTP 请求
|
// 6. 发送 HTTP 请求
|
||||||
let response_data = send_http_request(&request, timeout_secs).await?;
|
let response_data = send_http_request(&request, timeout_secs).await?;
|
||||||
@@ -472,7 +481,11 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 验证请求 URL 是否安全(防止 SSRF)
|
/// 验证请求 URL 是否安全(防止 SSRF)
|
||||||
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
|
fn validate_request_url(
|
||||||
|
request_url: &str,
|
||||||
|
base_url: &str,
|
||||||
|
is_custom_template: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
// 解析请求 URL
|
// 解析请求 URL
|
||||||
let parsed_request = Url::parse(request_url).map_err(|e| {
|
let parsed_request = Url::parse(request_url).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -482,19 +495,11 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 解析 base URL
|
|
||||||
let parsed_base = Url::parse(base_url).map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.base_url_invalid",
|
|
||||||
format!("无效的 base_url: {e}"),
|
|
||||||
format!("Invalid base_url: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let is_request_loopback = is_loopback_host(&parsed_request);
|
let is_request_loopback = is_loopback_host(&parsed_request);
|
||||||
|
|
||||||
// 必须使用 HTTPS(允许 localhost 用于开发)
|
// 必须使用 HTTPS(允许 localhost 用于开发)
|
||||||
if parsed_request.scheme() != "https" && !is_request_loopback {
|
// 自定义模板模式下,允许用户自行决定是否使用 HTTP(用户需自行承担安全风险)
|
||||||
|
if !is_custom_template && parsed_request.scheme() != "https" && !is_request_loopback {
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"usage_script.request_https_required",
|
"usage_script.request_https_required",
|
||||||
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
|
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
|
||||||
@@ -502,60 +507,85 @@ fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppErro
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
|
// 如果提供了 base_url(非空),则进行同源检查
|
||||||
if parsed_request.host_str() != parsed_base.host_str() {
|
// 🔧 自定义模板模式下,用户可以自由访问任意 HTTPS 域名,跳过同源检查
|
||||||
return Err(AppError::localized(
|
if !base_url.is_empty() && !is_custom_template {
|
||||||
"usage_script.request_host_mismatch",
|
// 解析 base URL
|
||||||
format!(
|
let parsed_base = Url::parse(base_url).map_err(|e| {
|
||||||
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
|
AppError::localized(
|
||||||
parsed_request.host_str().unwrap_or("unknown"),
|
"usage_script.base_url_invalid",
|
||||||
parsed_base.host_str().unwrap_or("unknown")
|
format!("无效的 base_url: {e}"),
|
||||||
),
|
format!("Invalid base_url: {e}"),
|
||||||
format!(
|
)
|
||||||
"Request host {} must match base_url host {} (same-origin required)",
|
})?;
|
||||||
parsed_request.host_str().unwrap_or("unknown"),
|
|
||||||
parsed_base.host_str().unwrap_or("unknown")
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查端口是否匹配(考虑默认端口)
|
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
|
||||||
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
if parsed_request.host_str() != parsed_base.host_str() {
|
||||||
match (
|
|
||||||
parsed_request.port_or_known_default(),
|
|
||||||
parsed_base.port_or_known_default(),
|
|
||||||
) {
|
|
||||||
(Some(request_port), Some(base_port)) if request_port == base_port => {
|
|
||||||
// 端口匹配,继续执行
|
|
||||||
}
|
|
||||||
(Some(request_port), Some(base_port)) => {
|
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"usage_script.request_port_mismatch",
|
"usage_script.request_host_mismatch",
|
||||||
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
|
format!(
|
||||||
format!("Request port {request_port} must match base_url port {base_port}"),
|
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
|
||||||
|
parsed_request.host_str().unwrap_or("unknown"),
|
||||||
|
parsed_base.host_str().unwrap_or("unknown")
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"Request host {} must match base_url host {} (same-origin required)",
|
||||||
|
parsed_request.host_str().unwrap_or("unknown"),
|
||||||
|
parsed_base.host_str().unwrap_or("unknown")
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
_ => {
|
|
||||||
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
|
// 检查端口是否匹配(考虑默认端口)
|
||||||
return Err(AppError::localized(
|
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
||||||
"usage_script.request_port_unknown",
|
match (
|
||||||
"无法确定端口号",
|
parsed_request.port_or_known_default(),
|
||||||
"Unable to determine port number",
|
parsed_base.port_or_known_default(),
|
||||||
));
|
) {
|
||||||
|
(Some(request_port), Some(base_port)) if request_port == base_port => {
|
||||||
|
// 端口匹配,继续执行
|
||||||
|
}
|
||||||
|
(Some(request_port), Some(base_port)) => {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.request_port_mismatch",
|
||||||
|
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
|
||||||
|
format!("Request port {request_port} must match base_url port {base_port}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.request_port_unknown",
|
||||||
|
"无法确定端口号",
|
||||||
|
"Unable to determine port number",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
||||||
if let Some(host) = parsed_request.host_str() {
|
if let Some(host) = parsed_request.host_str() {
|
||||||
let base_host = parsed_base.host_str().unwrap_or("");
|
let base_host = parsed_base.host_str().unwrap_or("");
|
||||||
|
|
||||||
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
||||||
if !is_private_ip(base_host) && is_private_ip(host) {
|
if !is_private_ip(base_host) && is_private_ip(host) {
|
||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"usage_script.private_ip_blocked",
|
"usage_script.private_ip_blocked",
|
||||||
"禁止访问私有 IP 地址",
|
"禁止访问私有 IP 地址",
|
||||||
"Access to private IP addresses is blocked",
|
"Access to private IP addresses is blocked",
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 自定义模板模式:没有 base_url,需要额外的安全检查
|
||||||
|
// 禁止访问私有 IP 地址(SSRF 防护)
|
||||||
|
if let Some(host) = parsed_request.host_str() {
|
||||||
|
if is_private_ip(host) && !is_request_loopback {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"usage_script.private_ip_blocked",
|
||||||
|
"禁止访问私有 IP 地址(localhost 除外)",
|
||||||
|
"Access to private IP addresses is blocked (localhost allowed)",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,7 +873,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (base_url, request_url, should_match) in test_cases {
|
for (base_url, request_url, should_match) in test_cases {
|
||||||
let result = validate_request_url(request_url, base_url);
|
let result = validate_request_url(request_url, base_url, false);
|
||||||
|
|
||||||
if should_match {
|
if should_match {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+16
-15
@@ -619,8 +619,8 @@ function App() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative inline-flex items-center">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/farion1231/cc-switch"
|
href="https://github.com/farion1231/cc-switch"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -634,26 +634,27 @@ function App() {
|
|||||||
>
|
>
|
||||||
CC Switch
|
CC Switch
|
||||||
</a>
|
</a>
|
||||||
<Button
|
<UpdateBadge
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSettingsDefaultTab("general");
|
setSettingsDefaultTab("about");
|
||||||
setCurrentView("settings");
|
setCurrentView("settings");
|
||||||
}}
|
}}
|
||||||
title={t("common.settings")}
|
className="absolute -top-4 -right-4"
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
/>
|
||||||
>
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<UpdateBadge
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSettingsDefaultTab("about");
|
setSettingsDefaultTab("general");
|
||||||
setCurrentView("settings");
|
setCurrentView("settings");
|
||||||
}}
|
}}
|
||||||
/>
|
title={t("common.settings")}
|
||||||
</>
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { X, Download } from "lucide-react";
|
|
||||||
import { useUpdate } from "@/contexts/UpdateContext";
|
import { useUpdate } from "@/contexts/UpdateContext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface UpdateBadgeProps {
|
interface UpdateBadgeProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -8,56 +8,39 @@ interface UpdateBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
||||||
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
|
const { hasUpdate, updateInfo } = useUpdate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isActive = hasUpdate && updateInfo;
|
||||||
|
const title = isActive
|
||||||
|
? t("settings.updateAvailable", {
|
||||||
|
version: updateInfo?.availableVersion ?? "",
|
||||||
|
})
|
||||||
|
: t("settings.checkForUpdates");
|
||||||
|
|
||||||
// 如果没有更新或已关闭,不显示
|
if (!isActive) {
|
||||||
if (!hasUpdate || isDismissed || !updateInfo) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
onClick={onClick}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-1.5 px-2.5 py-1
|
relative h-6 w-6 rounded-full
|
||||||
bg-white dark:bg-gray-800
|
${isActive ? "text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-500/10" : "text-muted-foreground hover:bg-muted/60"}
|
||||||
border border-border-default
|
|
||||||
rounded-lg text-xs
|
|
||||||
shadow-sm
|
|
||||||
transition-all duration-200
|
|
||||||
${onClick ? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750" : ""}
|
|
||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
role={onClick ? "button" : undefined}
|
|
||||||
tabIndex={onClick ? 0 : -1}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (!onClick) return;
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3 text-blue-500 dark:text-blue-400" />
|
<span
|
||||||
<span className="text-gray-700 dark:text-gray-300 font-medium">
|
className={`
|
||||||
{t("settings.updateBadge")}
|
absolute inset-0 m-auto h-2 w-2 rounded-full ring-1 ring-background
|
||||||
</span>
|
${isActive ? "bg-blue-500 dark:bg-blue-400" : "bg-blue-300/70 dark:bg-blue-300/60"}
|
||||||
<button
|
`}
|
||||||
onClick={(e) => {
|
/>
|
||||||
e.stopPropagation();
|
</Button>
|
||||||
dismissUpdate();
|
|
||||||
}}
|
|
||||||
className="
|
|
||||||
ml-1 -mr-0.5 p-0.5 rounded
|
|
||||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
|
||||||
transition-colors
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500/20
|
|
||||||
"
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import React, { useState } from "react";
|
|||||||
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Provider, UsageScript, UsageData } from "@/types";
|
import { Provider, UsageScript, UsageData } from "@/types";
|
||||||
import { usageApi, type AppId } from "@/lib/api";
|
import { usageApi, type AppId } from "@/lib/api";
|
||||||
|
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||||
import JsonEditor from "./JsonEditor";
|
import JsonEditor from "./JsonEditor";
|
||||||
import * as prettier from "prettier/standalone";
|
import * as prettier from "prettier/standalone";
|
||||||
import * as parserBabel from "prettier/parser-babel";
|
import * as parserBabel from "prettier/parser-babel";
|
||||||
@@ -109,19 +111,67 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// 生成带国际化的预设模板
|
// 生成带国际化的预设模板
|
||||||
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
||||||
|
|
||||||
const [script, setScript] = useState<UsageScript>(() => {
|
// 从 provider 的 settingsConfig 中提取 API Key 和 Base URL
|
||||||
return (
|
const getProviderCredentials = (): {
|
||||||
provider.meta?.usage_script || {
|
apiKey: string | undefined;
|
||||||
enabled: false,
|
baseUrl: string | undefined;
|
||||||
language: "javascript",
|
} => {
|
||||||
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
|
try {
|
||||||
timeout: 10,
|
const config = provider.settingsConfig;
|
||||||
|
if (!config) return { apiKey: undefined, baseUrl: undefined };
|
||||||
|
|
||||||
|
// 处理不同应用的配置格式
|
||||||
|
if (appId === "claude") {
|
||||||
|
// Claude: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } }
|
||||||
|
const env = (config as any).env || {};
|
||||||
|
return {
|
||||||
|
apiKey: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY,
|
||||||
|
baseUrl: env.ANTHROPIC_BASE_URL,
|
||||||
|
};
|
||||||
|
} else if (appId === "codex") {
|
||||||
|
// Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }
|
||||||
|
const auth = (config as any).auth || {};
|
||||||
|
const configToml = (config as any).config || "";
|
||||||
|
return {
|
||||||
|
apiKey: auth.OPENAI_API_KEY,
|
||||||
|
baseUrl: extractCodexBaseUrl(configToml),
|
||||||
|
};
|
||||||
|
} else if (appId === "gemini") {
|
||||||
|
// Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } }
|
||||||
|
const env = (config as any).env || {};
|
||||||
|
return {
|
||||||
|
apiKey: env.GEMINI_API_KEY,
|
||||||
|
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
);
|
return { apiKey: undefined, baseUrl: undefined };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to extract provider credentials:", error);
|
||||||
|
return { apiKey: undefined, baseUrl: undefined };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerCredentials = getProviderCredentials();
|
||||||
|
|
||||||
|
const [script, setScript] = useState<UsageScript>(() => {
|
||||||
|
const savedScript = provider.meta?.usage_script;
|
||||||
|
const defaultScript = {
|
||||||
|
enabled: false,
|
||||||
|
language: "javascript" as const,
|
||||||
|
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
|
||||||
|
timeout: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!savedScript) {
|
||||||
|
return defaultScript;
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedScript;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
@@ -176,6 +226,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
||||||
() => {
|
() => {
|
||||||
const existingScript = provider.meta?.usage_script;
|
const existingScript = provider.meta?.usage_script;
|
||||||
|
// 优先使用保存的 templateType
|
||||||
|
if (existingScript?.templateType) {
|
||||||
|
return existingScript.templateType;
|
||||||
|
}
|
||||||
|
// 向后兼容:根据字段推断模板类型
|
||||||
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
||||||
if (existingScript?.accessToken || existingScript?.userId) {
|
if (existingScript?.accessToken || existingScript?.userId) {
|
||||||
return TEMPLATE_KEYS.NEW_API;
|
return TEMPLATE_KEYS.NEW_API;
|
||||||
@@ -201,7 +256,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave(script);
|
// 保存时记录当前选择的模板类型
|
||||||
|
const scriptWithTemplate = {
|
||||||
|
...script,
|
||||||
|
templateType: selectedTemplate as
|
||||||
|
| "custom"
|
||||||
|
| "general"
|
||||||
|
| "newapi"
|
||||||
|
| undefined,
|
||||||
|
};
|
||||||
|
onSave(scriptWithTemplate);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,6 +281,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
script.baseUrl,
|
script.baseUrl,
|
||||||
script.accessToken,
|
script.accessToken,
|
||||||
script.userId,
|
script.userId,
|
||||||
|
selectedTemplate as "custom" | "general" | "newapi" | undefined,
|
||||||
);
|
);
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
const summary = result.data
|
const summary = result.data
|
||||||
@@ -229,6 +294,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔧 测试成功后,更新主界面列表的用量查询缓存
|
||||||
|
queryClient.setQueryData(["usage", provider.id, appId], result);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
|
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
|
||||||
@@ -278,9 +346,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
const preset = PRESET_TEMPLATES[presetName];
|
const preset = PRESET_TEMPLATES[presetName];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
||||||
|
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
|
||||||
|
// 这样可以避免同源检查导致的问题
|
||||||
|
// 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
code: preset,
|
code: preset,
|
||||||
|
// 清除凭证,用户可选择手动输入或保持空
|
||||||
apiKey: undefined,
|
apiKey: undefined,
|
||||||
baseUrl: undefined,
|
baseUrl: undefined,
|
||||||
accessToken: undefined,
|
accessToken: undefined,
|
||||||
@@ -401,6 +473,74 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 自定义模式:变量提示和具体值 */}
|
||||||
|
{selectedTemplate === TEMPLATE_KEYS.CUSTOM && (
|
||||||
|
<div className="space-y-2 border-t border-white/10 pt-3">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">
|
||||||
|
{t("usageScript.supportedVariables")}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
{/* baseUrl */}
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
|
||||||
|
{"{{baseUrl}}"}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground/50">=</span>
|
||||||
|
{providerCredentials.baseUrl ? (
|
||||||
|
<code className="text-foreground/70 break-all font-mono">
|
||||||
|
{providerCredentials.baseUrl}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/50 italic">
|
||||||
|
{t("common.notSet") || "未设置"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* apiKey */}
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
|
||||||
|
{"{{apiKey}}"}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground/50">=</span>
|
||||||
|
{providerCredentials.apiKey ? (
|
||||||
|
<>
|
||||||
|
{showApiKey ? (
|
||||||
|
<code className="text-foreground/70 break-all font-mono">
|
||||||
|
{providerCredentials.apiKey}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<code className="text-foreground/70 font-mono">
|
||||||
|
••••••••
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowApiKey(!showApiKey)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors ml-1"
|
||||||
|
aria-label={
|
||||||
|
showApiKey
|
||||||
|
? t("apiKeyInput.hide")
|
||||||
|
: t("apiKeyInput.show")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showApiKey ? (
|
||||||
|
<EyeOff size={12} />
|
||||||
|
) : (
|
||||||
|
<Eye size={12} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/50 italic">
|
||||||
|
{t("common.notSet") || "未设置"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 凭证配置 */}
|
{/* 凭证配置 */}
|
||||||
{shouldShowCredentialsConfig && (
|
{shouldShowCredentialsConfig && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -601,11 +741,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={1440}
|
max={1440}
|
||||||
value={script.autoIntervalMinutes ?? 0}
|
value={
|
||||||
|
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 0
|
||||||
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
autoIntervalMinutes: validateAndClampInterval(
|
autoQueryInterval: validateAndClampInterval(
|
||||||
e.target.value,
|
e.target.value,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -613,7 +755,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
onBlur={(e) =>
|
onBlur={(e) =>
|
||||||
setScript({
|
setScript({
|
||||||
...script,
|
...script,
|
||||||
autoIntervalMinutes: validateAndClampInterval(
|
autoQueryInterval: validateAndClampInterval(
|
||||||
e.target.value,
|
e.target.value,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ export function useProviderActions(activeApp: AppId) {
|
|||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["providers", activeApp],
|
queryKey: ["providers", activeApp],
|
||||||
});
|
});
|
||||||
|
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
|
||||||
|
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["usage", provider.id, activeApp],
|
||||||
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
t("provider.usageSaved", {
|
t("provider.usageSaved", {
|
||||||
defaultValue: "用量查询配置已保存",
|
defaultValue: "用量查询配置已保存",
|
||||||
|
|||||||
@@ -584,6 +584,7 @@
|
|||||||
"testFailed": "Test failed",
|
"testFailed": "Test failed",
|
||||||
"formatSuccess": "Format successful",
|
"formatSuccess": "Format successful",
|
||||||
"formatFailed": "Format failed",
|
"formatFailed": "Format failed",
|
||||||
|
"supportedVariables": "Supported Variables",
|
||||||
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
|
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
|
||||||
"scriptConfig": "Request configuration",
|
"scriptConfig": "Request configuration",
|
||||||
"extractorCode": "Extractor code",
|
"extractorCode": "Extractor code",
|
||||||
|
|||||||
@@ -584,6 +584,7 @@
|
|||||||
"testFailed": "テストに失敗しました",
|
"testFailed": "テストに失敗しました",
|
||||||
"formatSuccess": "整形に成功しました",
|
"formatSuccess": "整形に成功しました",
|
||||||
"formatFailed": "整形に失敗しました",
|
"formatFailed": "整形に失敗しました",
|
||||||
|
"supportedVariables": "使用可能な変数",
|
||||||
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
|
"variablesHint": "使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます",
|
||||||
"scriptConfig": "リクエスト設定",
|
"scriptConfig": "リクエスト設定",
|
||||||
"extractorCode": "抽出コード",
|
"extractorCode": "抽出コード",
|
||||||
|
|||||||
@@ -584,6 +584,7 @@
|
|||||||
"testFailed": "测试失败",
|
"testFailed": "测试失败",
|
||||||
"formatSuccess": "格式化成功",
|
"formatSuccess": "格式化成功",
|
||||||
"formatFailed": "格式化失败",
|
"formatFailed": "格式化失败",
|
||||||
|
"supportedVariables": "支持的变量",
|
||||||
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
|
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
|
||||||
"scriptConfig": "请求配置",
|
"scriptConfig": "请求配置",
|
||||||
"extractorCode": "提取器代码",
|
"extractorCode": "提取器代码",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const usageApi = {
|
|||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
accessToken?: string,
|
accessToken?: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
|
templateType?: "custom" | "general" | "newapi",
|
||||||
): Promise<UsageResult> => {
|
): Promise<UsageResult> => {
|
||||||
return invoke("testUsageScript", {
|
return invoke("testUsageScript", {
|
||||||
providerId,
|
providerId,
|
||||||
@@ -38,6 +39,7 @@ export const usageApi = {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
accessToken,
|
accessToken,
|
||||||
userId,
|
userId,
|
||||||
|
templateType,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface UsageScript {
|
|||||||
language: "javascript"; // 脚本语言
|
language: "javascript"; // 脚本语言
|
||||||
code: string; // 脚本代码(JSON 格式配置)
|
code: string; // 脚本代码(JSON 格式配置)
|
||||||
timeout?: number; // 超时时间(秒,默认 10)
|
timeout?: number; // 超时时间(秒,默认 10)
|
||||||
|
templateType?: "custom" | "general" | "newapi"; // 模板类型(用于后端判断验证规则)
|
||||||
apiKey?: string; // 用量查询专用的 API Key(通用模板使用)
|
apiKey?: string; // 用量查询专用的 API Key(通用模板使用)
|
||||||
baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用)
|
baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用)
|
||||||
accessToken?: string; // 访问令牌(NewAPI 模板使用)
|
accessToken?: string; // 访问令牌(NewAPI 模板使用)
|
||||||
|
|||||||
Reference in New Issue
Block a user