feat(usage-query): decouple credentials from provider config

Add independent credential fields for usage query to support different
query endpoints and authentication methods.

Changes:
- Add `apiKey` and `baseUrl` fields to UsageScript struct
- Remove dependency on provider config credentials in query_usage
- Update test_usage_script to accept independent credential parameters
- Add credential input fields in UsageScriptModal based on template:
  * General: apiKey + baseUrl
  * NewAPI: baseUrl + accessToken + userId
  * Custom: no additional fields (full freedom)
- Auto-clear irrelevant fields when switching templates
- Add i18n text for "credentialsConfig"

Benefits:
- Query API can use different endpoint/key than provider config
- Better separation of concerns
- More flexible for various usage query scenarios
This commit is contained in:
Jason
2025-11-10 15:28:09 +08:00
parent 23d06515ad
commit 7096957b40
8 changed files with 178 additions and 92 deletions

View File

@@ -123,6 +123,7 @@ pub async fn queryProviderUsage(
/// 测试用量脚本(使用当前编辑器中的脚本,不保存)
#[allow(non_snake_case)]
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn testUsageScript(
state: State<'_, AppState>,
@@ -130,6 +131,8 @@ pub async fn testUsageScript(
app: String,
#[allow(non_snake_case)] scriptCode: String,
timeout: Option<u64>,
#[allow(non_snake_case)] apiKey: Option<String>,
#[allow(non_snake_case)] baseUrl: Option<String>,
#[allow(non_snake_case)] accessToken: Option<String>,
#[allow(non_snake_case)] userId: Option<String>,
) -> Result<crate::provider::UsageResult, String> {
@@ -140,6 +143,8 @@ pub async fn testUsageScript(
&providerId,
&scriptCode,
timeout.unwrap_or(10),
apiKey.as_deref(),
baseUrl.as_deref(),
accessToken.as_deref(),
userId.as_deref(),
)

View File

@@ -63,11 +63,19 @@ pub struct UsageScript {
pub code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
/// 访问令牌(用于需要登录的接口
/// 用量查询专用的 API Key通用模板使用
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "apiKey")]
pub api_key: Option<String>,
/// 用量查询专用的 Base URL通用和 NewAPI 模板使用)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "baseUrl")]
pub base_url: Option<String>,
/// 访问令牌用于需要登录的接口NewAPI 模板使用)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "accessToken")]
pub access_token: Option<String>,
/// 用户ID用于需要用户标识的接口
/// 用户ID用于需要用户标识的接口NewAPI 模板使用
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "userId")]
pub user_id: Option<String>,

View File

@@ -802,7 +802,7 @@ impl ProviderService {
app_type: AppType,
provider_id: &str,
) -> Result<UsageResult, AppError> {
let (provider, script_code, timeout, access_token, user_id) = {
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
let config = state.config.read().map_err(AppError::from)?;
let manager = config
.get_manager(&app_type)
@@ -814,38 +814,37 @@ impl ProviderService {
format!("Provider not found: {}", provider_id),
)
})?;
let (script_code, timeout, access_token, user_id) = {
let usage_script = provider
.meta
.as_ref()
.and_then(|m| m.usage_script.as_ref())
.ok_or_else(|| {
AppError::localized(
"provider.usage.script.missing",
"未配置用量查询脚本",
"Usage script is not configured",
)
})?;
if !usage_script.enabled {
return Err(AppError::localized(
"provider.usage.disabled",
"用量查询未启用",
"Usage query is disabled",
));
}
(
usage_script.code.clone(),
usage_script.timeout.unwrap_or(10),
usage_script.access_token.clone(),
usage_script.user_id.clone(),
)
};
(provider, script_code, timeout, access_token, user_id)
let usage_script = provider
.meta
.as_ref()
.and_then(|m| m.usage_script.as_ref())
.ok_or_else(|| {
AppError::localized(
"provider.usage.script.missing",
"未配置用量查询脚本",
"Usage script is not configured",
)
})?;
if !usage_script.enabled {
return Err(AppError::localized(
"provider.usage.disabled",
"用量查询未启用",
"Usage query is disabled",
));
}
// 直接从 UsageScript 中获取凭证,不再从供应商配置提取
(
usage_script.code.clone(),
usage_script.timeout.unwrap_or(10),
usage_script.api_key.clone().unwrap_or_default(),
usage_script.base_url.clone().unwrap_or_default(),
usage_script.access_token.clone(),
usage_script.user_id.clone(),
)
};
let (api_key, base_url) = Self::extract_credentials(&provider, &app_type)?;
Self::execute_and_format_usage_result(
&script_code,
&api_key,
@@ -858,36 +857,23 @@ impl ProviderService {
}
/// 测试用量脚本(使用临时脚本内容,不保存)
#[allow(clippy::too_many_arguments)]
pub async fn test_usage_script(
state: &AppState,
app_type: AppType,
provider_id: &str,
_state: &AppState,
_app_type: AppType,
_provider_id: &str,
script_code: &str,
timeout: u64,
api_key: Option<&str>,
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
) -> Result<UsageResult, AppError> {
// 获取 provider 的 API 凭证
let provider = {
let config = state.config.read().map_err(AppError::from)?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| Self::app_not_found(&app_type))?;
manager.providers.get(provider_id).cloned().ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {}", provider_id),
format!("Provider not found: {}", provider_id),
)
})?
};
let (api_key, base_url) = Self::extract_credentials(&provider, &app_type)?;
// 直接使用传入的凭证参数进行测试
Self::execute_and_format_usage_result(
script_code,
&api_key,
&base_url,
api_key.unwrap_or(""),
base_url.unwrap_or(""),
timeout,
access_token,
user_id,
@@ -1137,6 +1123,7 @@ impl ProviderService {
Ok(())
}
#[allow(dead_code)]
fn extract_credentials(
provider: &Provider,
app_type: &AppType,

View File

@@ -166,6 +166,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
appId,
script.code,
script.timeout,
script.apiKey,
script.baseUrl,
script.accessToken,
script.userId,
);
@@ -225,23 +227,40 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleUsePreset = (presetName: string) => {
const preset = PRESET_TEMPLATES[presetName];
if (preset) {
// 如果选择的不是 NewAPI 模板,清空高级配置字段
if (presetName !== TEMPLATE_KEYS.NEW_API) {
// 根据模板类型清空不同的字段
if (presetName === TEMPLATE_KEYS.CUSTOM) {
// 自定义:清空所有凭证字段
setScript({
...script,
code: preset,
apiKey: undefined,
baseUrl: undefined,
accessToken: undefined,
userId: undefined,
});
} else if (presetName === TEMPLATE_KEYS.GENERAL) {
// 通用:保留 apiKey 和 baseUrl清空 NewAPI 字段
setScript({
...script,
code: preset,
accessToken: undefined,
userId: undefined,
});
} else {
setScript({ ...script, code: preset });
} else if (presetName === TEMPLATE_KEYS.NEW_API) {
// NewAPI清空 apiKeyNewAPI 不使用通用的 apiKey
setScript({
...script,
code: preset,
apiKey: undefined,
});
}
setSelectedTemplate(presetName); // 记录选择的模板
}
};
// 判断是否应该显示高级配置(仅 NewAPI 模板需要)
const shouldShowAdvancedConfig = selectedTemplate === TEMPLATE_KEYS.NEW_API;
// 判断是否应该显示凭证配置区域
const shouldShowCredentialsConfig =
selectedTemplate === TEMPLATE_KEYS.GENERAL || selectedTemplate === TEMPLATE_KEYS.NEW_API;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
@@ -296,38 +315,97 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
</div>
{/* 高级配置Access Token 和 User ID NewAPI 模板显示 */}
{shouldShowAdvancedConfig && (
{/* 凭证配置区域:通用和 NewAPI 模板显示 */}
{shouldShowCredentialsConfig && (
<div className="space-y-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<label className="block">
<span className="text-xs text-gray-600 dark:text-gray-400">
{t("usageScript.accessToken")}
</span>
<input
type="text"
value={script.accessToken || ""}
onChange={(e) =>
setScript({ ...script, accessToken: e.target.value })
}
placeholder={t("usageScript.accessTokenPlaceholder")}
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
</label>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t("usageScript.credentialsConfig")}
</h4>
<label className="block">
<span className="text-xs text-gray-600 dark:text-gray-400">
{t("usageScript.userId")}
</span>
<input
type="text"
value={script.userId || ""}
onChange={(e) =>
setScript({ ...script, userId: e.target.value })
}
placeholder={t("usageScript.userIdPlaceholder")}
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
</label>
{/* 通用模板:显示 apiKey + baseUrl */}
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
<>
<label className="block">
<span className="text-xs text-gray-600 dark:text-gray-400">
API Key
</span>
<input
type="password"
value={script.apiKey || ""}
onChange={(e) =>
setScript({ ...script, apiKey: e.target.value })
}
placeholder="sk-xxxxx"
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
</label>
<label className="block">
<span className="text-xs text-gray-600 dark:text-gray-400">
Base URL
</span>
<input
type="text"
value={script.baseUrl || ""}
onChange={(e) =>
setScript({ ...script, baseUrl: e.target.value })
}
placeholder="https://api.example.com"
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
</label>
</>
)}
{/* NewAPI 模板:显示 baseUrl + accessToken + userId */}
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
<>
<label className="block">
<span className="text-xs text-gray-600 dark:text-gray-400">
Base URL
</span>
<input
type="text"
value={script.baseUrl || ""}
onChange={(e) =>
setScript({ ...script, baseUrl: e.target.value })
}
placeholder="https://api.newapi.com"
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
</label>
<label className="block">
<span className="text-xs text-gray-600 dark:text-gray-400">
{t("usageScript.accessToken")}
</span>
<input
type="text"
value={script.accessToken || ""}
onChange={(e) =>
setScript({ ...script, accessToken: e.target.value })
}
placeholder={t("usageScript.accessTokenPlaceholder")}
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
</label>
<label className="block">
<span className="text-xs text-gray-600 dark:text-gray-400">
{t("usageScript.userId")}
</span>
<input
type="text"
value={script.userId || ""}
onChange={(e) =>
setScript({ ...script, userId: e.target.value })
}
placeholder={t("usageScript.userIdPlaceholder")}
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
</label>
</>
)}
</div>
)}

View File

@@ -356,6 +356,7 @@
"templateCustom": "Custom",
"templateGeneral": "General",
"templateNewAPI": "NewAPI",
"credentialsConfig": "Credentials",
"accessToken": "Access Token",
"accessTokenPlaceholder": "Generate in 'Security Settings'",
"userId": "User ID",

View File

@@ -356,6 +356,7 @@
"templateCustom": "自定义",
"templateGeneral": "通用模板",
"templateNewAPI": "NewAPI",
"credentialsConfig": "凭证配置",
"accessToken": "访问令牌",
"accessTokenPlaceholder": "在'安全设置'里生成",
"userId": "用户 ID",

View File

@@ -32,6 +32,8 @@ export const usageApi = {
appId: AppId,
scriptCode: string,
timeout?: number,
apiKey?: string,
baseUrl?: string,
accessToken?: string,
userId?: string,
): Promise<UsageResult> {
@@ -41,6 +43,8 @@ export const usageApi = {
app: appId,
scriptCode: scriptCode,
timeout: timeout,
apiKey: apiKey,
baseUrl: baseUrl,
accessToken: accessToken,
userId: userId,
});

View File

@@ -45,8 +45,10 @@ export interface UsageScript {
language: "javascript"; // 脚本语言
code: string; // 脚本代码JSON 格式配置)
timeout?: number; // 超时时间(秒,默认 10
accessToken?: string; // 访问令牌(用于需要登录的接口
userId?: string; // 用户ID用于需要用户标识的接口
apiKey?: string; // 用量查询专用的 API Key通用模板使用
baseUrl?: string; // 用量查询专用的 Base URL通用和 NewAPI 模板使用
accessToken?: string; // 访问令牌NewAPI 模板使用)
userId?: string; // 用户IDNewAPI 模板使用)
autoQueryInterval?: number; // 自动查询间隔单位分钟0 表示禁用)
}