diff --git a/docs/user-manual/en/2-providers/2.5-usage-query.md b/docs/user-manual/en/2-providers/2.5-usage-query.md index 922341853..2b66e613f 100644 --- a/docs/user-manual/en/2-providers/2.5-usage-query.md +++ b/docs/user-manual/en/2-providers/2.5-usage-query.md @@ -176,18 +176,18 @@ Interval for automatically refreshing usage data (minutes): ## Extractor Return Format -The extractor function must return an object containing the following fields: +The extractor function returns an object containing the following fields. All fields are optional: | Field | Type | Required | Description | |-------|------|----------|-------------| | `isValid` | boolean | No | Whether the account is valid, defaults to true | | `invalidMessage` | string | No | Message when invalid | -| `remaining` | number | Yes | Remaining balance | -| `unit` | string | Yes | Unit (e.g., USD, CNY, times) | +| `remaining` | number | No | Remaining balance | +| `unit` | string | No | Unit (e.g., USD, CNY, times) | | `planName` | string | No | Plan name (supports multi-plan) | | `total` | number | No | Total balance | | `used` | number | No | Used amount | -| `extra` | object | No | Additional information | +| `extra` | string | No | Additional display text | ## Test Script diff --git a/docs/user-manual/ja/2-providers/2.5-usage-query.md b/docs/user-manual/ja/2-providers/2.5-usage-query.md index 00dd4ec12..7adc23e50 100644 --- a/docs/user-manual/ja/2-providers/2.5-usage-query.md +++ b/docs/user-manual/ja/2-providers/2.5-usage-query.md @@ -176,18 +176,18 @@ New API タイプの中継サービス専用に設計されています: ## エクストラクターの戻り値形式 -エクストラクター関数は以下のフィールドを含むオブジェクトを返す必要があります: +エクストラクター関数は以下のフィールドを含むオブジェクトを返します。すべてのフィールドは任意です: | フィールド | 型 | 必須 | 説明 | |------|------|------|------| | `isValid` | boolean | いいえ | アカウントが有効かどうか、デフォルト true | | `invalidMessage` | string | いいえ | 無効時の通知メッセージ | -| `remaining` | number | はい | 残額 | -| `unit` | string | はい | 単位(例:USD、CNY、回) | +| `remaining` | number | いいえ | 残額 | +| `unit` | string | いいえ | 単位(例:USD、CNY、回) | | `planName` | string | いいえ | プラン名(複数プラン対応) | | `total` | number | いいえ | 総額 | | `used` | number | いいえ | 使用済み額 | -| `extra` | object | いいえ | 追加情報 | +| `extra` | string | いいえ | 追加表示テキスト | ## スクリプトのテスト diff --git a/docs/user-manual/zh/2-providers/2.5-usage-query.md b/docs/user-manual/zh/2-providers/2.5-usage-query.md index 858cc8e49..e78548e69 100644 --- a/docs/user-manual/zh/2-providers/2.5-usage-query.md +++ b/docs/user-manual/zh/2-providers/2.5-usage-query.md @@ -176,18 +176,18 @@ CC Switch 提供三种预设模板: ## 提取器返回格式 -提取器函数需要返回包含以下字段的对象: +提取器函数返回包含以下字段的对象,所有字段均可选: | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `isValid` | boolean | 否 | 账户是否有效,默认 true | | `invalidMessage` | string | 否 | 无效时的提示信息 | -| `remaining` | number | 是 | 剩余额度 | -| `unit` | string | 是 | 单位(如 USD、CNY、次) | +| `remaining` | number | 否 | 剩余额度 | +| `unit` | string | 否 | 单位(如 USD、CNY、次) | | `planName` | string | 否 | 套餐名称(支持多套餐) | | `total` | number | 否 | 总额度 | | `used` | number | 否 | 已使用额度 | -| `extra` | object | 否 | 额外信息 | +| `extra` | string | 否 | 额外展示文本 | ## 测试脚本 diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index 7d121e4fa..8c83de4f1 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -25,7 +25,7 @@ pub async fn execute_usage_script( // 2. 验证 base_url 的安全性(仅当提供了 base_url 时) // 自定义模板模式下,用户可能不使用模板变量,而是直接在脚本中写完整 URL - if !base_url.is_empty() { + if should_validate_base_url(base_url, is_custom_template) { validate_base_url(base_url)?; } @@ -470,6 +470,10 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> { Ok(()) } +fn should_validate_base_url(base_url: &str, is_custom_template: bool) -> bool { + !base_url.is_empty() && !is_custom_template +} + /// 验证请求 URL 是否安全(HTTPS 强制 + 同源检查) fn validate_request_url( request_url: &str, @@ -580,6 +584,24 @@ mod tests { ); } + #[test] + fn test_custom_template_allows_http_lan_request_with_different_base_url() { + assert!( + !should_validate_base_url("http://10.37.192.156:8090/anthropic", true), + "Custom scripts should not validate an unused provider base_url fallback" + ); + + let result = validate_request_url( + "http://10.37.192.156:18344/user/balance", + "http://10.37.192.156:8090/anthropic", + true, + ); + assert!( + result.is_ok(), + "Custom usage scripts should be able to call an explicit HTTP quota endpoint" + ); + } + #[test] fn test_port_comparison() { // 测试端口比较逻辑是否正确处理默认端口和显式端口 diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index f8375dd33..5ba970aea 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -28,6 +28,7 @@ import { CODING_PLAN_PROVIDERS, detectCodingPlanProvider, } from "@/config/codingPlanProviders"; +import { formatUsageDataSummary } from "@/utils/usageDisplay"; interface UsageScriptModalProps { provider: Provider; @@ -394,10 +395,13 @@ const UsageScriptModal: React.FC = ({ const result = await subscriptionApi.getBalance(baseUrl, apiKey); if (result.success && result.data && result.data.length > 0) { const summary = result.data - .map((d) => { - const name = d.planName ? `[${d.planName}] ` : ""; - return `${name}${t("usage.remaining")} ${d.remaining?.toFixed(2)} ${d.unit || ""}`; - }) + .map((d) => + formatUsageDataSummary(d, { + invalid: t("usage.invalid"), + remaining: t("usage.remaining"), + used: t("usage.used"), + }), + ) .join(", "); toast.success(`${t("usageScript.testSuccess")}${summary}`, { duration: 3000, @@ -493,10 +497,13 @@ const UsageScriptModal: React.FC = ({ ); if (result.success && result.data && result.data.length > 0) { const summary = result.data - .map((plan: UsageData) => { - const planInfo = plan.planName ? `[${plan.planName}]` : ""; - return `${planInfo} ${t("usage.remaining")} ${plan.remaining} ${plan.unit}`; - }) + .map((plan: UsageData) => + formatUsageDataSummary(plan, { + invalid: t("usage.invalid"), + remaining: t("usage.remaining"), + used: t("usage.used"), + }), + ) .join(", "); toast.success(`${t("usageScript.testSuccess")}${summary}`, { duration: 3000, diff --git a/src/utils/usageDisplay.ts b/src/utils/usageDisplay.ts new file mode 100644 index 000000000..1bd0506bd --- /dev/null +++ b/src/utils/usageDisplay.ts @@ -0,0 +1,67 @@ +import type { UsageData } from "@/types"; + +interface UsageSummaryLabels { + invalid: string; + remaining: string; + used: string; +} + +function formatNumber(value: number): string { + return Number.isInteger(value) ? value.toString() : value.toFixed(2); +} + +function formatValue(value: number, unit?: string): string { + if (!unit) { + return formatNumber(value); + } + + return unit === "%" + ? `${formatNumber(value)}%` + : `${formatNumber(value)} ${unit}`; +} + +function isNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function formatUsed( + data: UsageData, + labels: UsageSummaryLabels, +): string | null { + if (!isNumber(data.used)) { + return null; + } + + if (isNumber(data.total) && data.total > 0) { + const usedPercent = (data.used / data.total) * 100; + + if (data.unit === "%" && data.total === 100) { + return `${labels.used} ${formatValue(data.used, "%")}`; + } + + return `${labels.used} ${formatNumber(usedPercent)}%`; + } + + return `${labels.used} ${formatValue(data.used, data.unit)}`; +} + +export function formatUsageDataSummary( + data: UsageData, + labels: UsageSummaryLabels, +): string { + const planPrefix = data.planName ? `[${data.planName}] ` : ""; + + if (data.isValid === false) { + return `${planPrefix}${data.invalidMessage || labels.invalid}`; + } + + const parts = [ + formatUsed(data, labels), + isNumber(data.remaining) + ? `${labels.remaining} ${formatValue(data.remaining, data.unit)}` + : null, + data.extra || null, + ].filter((part): part is string => Boolean(part)); + + return `${planPrefix}${parts.join(" / ") || labels.invalid}`; +} diff --git a/tests/utils/usageDisplay.test.ts b/tests/utils/usageDisplay.test.ts new file mode 100644 index 000000000..9526c2bd4 --- /dev/null +++ b/tests/utils/usageDisplay.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { formatUsageDataSummary } from "@/utils/usageDisplay"; + +const labels = { + invalid: "Invalid", + remaining: "Remaining:", + used: "Used:", +}; + +describe("formatUsageDataSummary", () => { + it("formats used percentage when remaining is omitted", () => { + expect( + formatUsageDataSummary( + { + planName: "Coco OpenRouter", + used: 55, + total: 100, + unit: "%", + }, + labels, + ), + ).toBe("[Coco OpenRouter] Used: 55%"); + }); + + it("formats remaining when present", () => { + expect( + formatUsageDataSummary( + { + planName: "Balance", + remaining: 12.5, + unit: "USD", + }, + labels, + ), + ).toBe("[Balance] Remaining: 12.50 USD"); + }); + + it("formats invalid results without requiring quota fields", () => { + expect( + formatUsageDataSummary( + { + isValid: false, + invalidMessage: "Unauthorized", + }, + labels, + ), + ).toBe("Unauthorized"); + }); +});