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(non_snake_case)]
#[allow(clippy::too_many_arguments)]
#[tauri::command] #[tauri::command]
pub async fn testUsageScript( pub async fn testUsageScript(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -130,6 +131,8 @@ pub async fn testUsageScript(
app: String, app: String,
#[allow(non_snake_case)] scriptCode: String, #[allow(non_snake_case)] scriptCode: String,
timeout: Option<u64>, 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)] accessToken: Option<String>,
#[allow(non_snake_case)] userId: Option<String>, #[allow(non_snake_case)] userId: Option<String>,
) -> Result<crate::provider::UsageResult, String> { ) -> Result<crate::provider::UsageResult, String> {
@@ -140,6 +143,8 @@ pub async fn testUsageScript(
&providerId, &providerId,
&scriptCode, &scriptCode,
timeout.unwrap_or(10), timeout.unwrap_or(10),
apiKey.as_deref(),
baseUrl.as_deref(),
accessToken.as_deref(), accessToken.as_deref(),
userId.as_deref(), userId.as_deref(),
) )

View File

@@ -63,11 +63,19 @@ pub struct UsageScript {
pub code: String, pub code: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>, 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(skip_serializing_if = "Option::is_none")]
#[serde(rename = "accessToken")] #[serde(rename = "accessToken")]
pub access_token: Option<String>, pub access_token: Option<String>,
/// 用户ID用于需要用户标识的接口 /// 用户ID用于需要用户标识的接口NewAPI 模板使用
#[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>,

View File

@@ -802,7 +802,7 @@ impl ProviderService {
app_type: AppType, app_type: AppType,
provider_id: &str, provider_id: &str,
) -> Result<UsageResult, AppError> { ) -> 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 config = state.config.read().map_err(AppError::from)?;
let manager = config let manager = config
.get_manager(&app_type) .get_manager(&app_type)
@@ -814,38 +814,37 @@ impl ProviderService {
format!("Provider not found: {}", provider_id), 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( Self::execute_and_format_usage_result(
&script_code, &script_code,
&api_key, &api_key,
@@ -858,36 +857,23 @@ impl ProviderService {
} }
/// 测试用量脚本(使用临时脚本内容,不保存) /// 测试用量脚本(使用临时脚本内容,不保存)
#[allow(clippy::too_many_arguments)]
pub async fn test_usage_script( pub async fn test_usage_script(
state: &AppState, _state: &AppState,
app_type: AppType, _app_type: AppType,
provider_id: &str, _provider_id: &str,
script_code: &str, script_code: &str,
timeout: u64, timeout: u64,
api_key: Option<&str>,
base_url: Option<&str>,
access_token: Option<&str>, access_token: Option<&str>,
user_id: Option<&str>, user_id: Option<&str>,
) -> Result<UsageResult, AppError> { ) -> 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( Self::execute_and_format_usage_result(
script_code, script_code,
&api_key, api_key.unwrap_or(""),
&base_url, base_url.unwrap_or(""),
timeout, timeout,
access_token, access_token,
user_id, user_id,
@@ -1137,6 +1123,7 @@ impl ProviderService {
Ok(()) Ok(())
} }
#[allow(dead_code)]
fn extract_credentials( fn extract_credentials(
provider: &Provider, provider: &Provider,
app_type: &AppType, app_type: &AppType,

View File

@@ -166,6 +166,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
appId, appId,
script.code, script.code,
script.timeout, script.timeout,
script.apiKey,
script.baseUrl,
script.accessToken, script.accessToken,
script.userId, script.userId,
); );
@@ -225,23 +227,40 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleUsePreset = (presetName: string) => { const handleUsePreset = (presetName: string) => {
const preset = PRESET_TEMPLATES[presetName]; const preset = PRESET_TEMPLATES[presetName];
if (preset) { 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({ setScript({
...script, ...script,
code: preset, code: preset,
accessToken: undefined, accessToken: undefined,
userId: undefined, userId: undefined,
}); });
} else { } else if (presetName === TEMPLATE_KEYS.NEW_API) {
setScript({ ...script, code: preset }); // NewAPI清空 apiKeyNewAPI 不使用通用的 apiKey
setScript({
...script,
code: preset,
apiKey: undefined,
});
} }
setSelectedTemplate(presetName); // 记录选择的模板 setSelectedTemplate(presetName); // 记录选择的模板
} }
}; };
// 判断是否应该显示高级配置(仅 NewAPI 模板需要) // 判断是否应该显示凭证配置区域
const shouldShowAdvancedConfig = selectedTemplate === TEMPLATE_KEYS.NEW_API; const shouldShowCredentialsConfig =
selectedTemplate === TEMPLATE_KEYS.GENERAL || selectedTemplate === TEMPLATE_KEYS.NEW_API;
return ( return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
@@ -296,38 +315,97 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div> </div>
</div> </div>
{/* 高级配置Access Token 和 User ID NewAPI 模板显示 */} {/* 凭证配置区域:通用和 NewAPI 模板显示 */}
{shouldShowAdvancedConfig && ( {shouldShowCredentialsConfig && (
<div className="space-y-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg"> <div className="space-y-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<label className="block"> <h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
<span className="text-xs text-gray-600 dark:text-gray-400"> {t("usageScript.credentialsConfig")}
{t("usageScript.accessToken")} </h4>
</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"> {/* 通用模板:显示 apiKey + baseUrl */}
<span className="text-xs text-gray-600 dark:text-gray-400"> {selectedTemplate === TEMPLATE_KEYS.GENERAL && (
{t("usageScript.userId")} <>
</span> <label className="block">
<input <span className="text-xs text-gray-600 dark:text-gray-400">
type="text" API Key
value={script.userId || ""} </span>
onChange={(e) => <input
setScript({ ...script, userId: e.target.value }) type="password"
} value={script.apiKey || ""}
placeholder={t("usageScript.userIdPlaceholder")} onChange={(e) =>
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" setScript({ ...script, apiKey: e.target.value })
/> }
</label> 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> </div>
)} )}

View File

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

View File

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

View File

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

View File

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