From 1a11cfbaaf5388cac9600344df8f300362b453f6 Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Sat, 5 Sep 2026 16:04:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(usage):=20CodingPlan.site=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=AF=86=E5=88=AB=20NewAPI=20+=20=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E4=BD=93=E9=AA=8C=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detect_provider 新增 codingplan.site → balance:newapi(该站为 NewAPI 实例,实测确认),保存补齐 TS 侧缺失的 balance:newapi 断言 - 凭据缺失等确定性错误改走 Ok(failure)(原 Err 被前端一律显示为 「网络异常」),错误消息按 kind 前缀包裹后仍可本地化映射 - formatAmount 收口到 usage-display 统一复用:¤ 占位符号不再渲染、 金额统一两位小数(配置弹窗此前直接拼原始数值) --- src-tauri/src/services/mod.rs | 83 +++++++++++++++++++++---- src/components/UsageConfigModal.tsx | 4 +- src/components/UsageFooter.tsx | 13 +--- src/config/providerPresets.ts | 2 + src/lib/usage-display.test.ts | 93 +++++++++++++++++++++++++++++ src/lib/usage-display.ts | 34 ++++++++--- 6 files changed, 198 insertions(+), 31 deletions(-) create mode 100644 src/lib/usage-display.test.ts diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index f149ff8..5f7ed0e 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -97,6 +97,11 @@ pub fn detect_provider(base_url: &str) -> Vec { if url.contains("openrouter.ai") { kinds.push(UsageKind::BalanceOpenrouter); } + // codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询); + // 家族匹配意味着 ai. 子域也自然命中该规则。 + if url.contains("codingplan.site") { + kinds.push(UsageKind::BalanceNewapi); + } if url.contains("api.stepfun.com") { kinds.push(UsageKind::BalanceStepfun); } @@ -126,6 +131,19 @@ pub fn detect_provider(base_url: &str) -> Vec { kinds } +/// BalanceNewapi 模板所需凭据(web 控制台 access token + user id)。 +/// 两者都必须存在且非空;返回 None 表示缺少凭据(确定性配置错误)。 +fn newapi_creds( + usage_config: Option<&crate::models::UsageConfig>, +) -> Option<(&str, &str)> { + usage_config.and_then(|c| { + c.access_token + .as_deref() + .zip(c.user_id.as_deref()) + .filter(|(t, u)| !t.is_empty() && !u.is_empty()) + }) +} + /// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的 /// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。 /// `usage_config` 仅 BalanceNewapi 分支读取(access_token / user_id), @@ -155,16 +173,13 @@ pub async fn query_kind( balance::query_kimi(api_key, !lower.contains("moonshot.ai"), timeout).await } UsageKind::BalanceNewapi => { - let (token, uid) = usage_config - .and_then(|c| { - c.access_token - .as_deref() - .zip(c.user_id.as_deref()) - .filter(|(t, u)| !t.is_empty() && !u.is_empty()) - }) - .ok_or_else(|| { - "newapi template requires accessToken and userId".to_string() - })?; + // 缺凭据是确定性配置错误 → Ok(failure)(前端按前缀本地化提示), + // 而非 Err(Err 语义 = 瞬时网络错误,见 commands.rs newapi 分支)。 + let Some((token, uid)) = newapi_creds(usage_config) else { + return Ok(UsageResult::failure( + "newapi template requires accessToken and userId".to_string(), + )); + }; let url = usage_config .and_then(|c| c.base_url.as_deref()) .filter(|s| !s.is_empty()) @@ -262,6 +277,28 @@ mod tests { ); } + #[test] + fn detect_provider_codingplan_site_family() { + // codingplan.site 是 NewAPI 实例(本工具仅支持 NewAPI 查询): + // 主域与 ai. 子域都命中家族规则,且只映射到 BalanceNewapi。 + assert_eq!( + detect_provider("https://codingplan.site"), + vec![UsageKind::BalanceNewapi] + ); + assert_eq!( + detect_provider("https://codingplan.site/v1"), + vec![UsageKind::BalanceNewapi] + ); + assert_eq!( + detect_provider("https://ai.codingplan.site"), + vec![UsageKind::BalanceNewapi] + ); + assert_eq!( + detect_provider("https://ai.codingplan.site/v1"), + vec![UsageKind::BalanceNewapi] + ); + } + #[test] fn usage_kind_string_roundtrip() { use std::str::FromStr; @@ -272,4 +309,30 @@ mod tests { assert!(UsageKind::from_str("balance:unknown").is_err()); assert!(UsageKind::from_str("").is_err()); } + + #[test] + fn newapi_creds_requires_both_and_nonempty() { + use crate::models::UsageConfig; + let cfg = |token: Option<&str>, uid: Option<&str>| UsageConfig { + enabled: true, + template_type: "newapi".to_string(), + base_url: None, + access_token: token.map(str::to_string), + user_id: uid.map(str::to_string), + auto_query_interval_minutes: None, + timeout_seconds: None, + }; + // 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误) + assert_eq!(newapi_creds(None), None); + assert_eq!(newapi_creds(Some(&cfg(None, None))), None); + assert_eq!(newapi_creds(Some(&cfg(Some("tok"), None))), None); + assert_eq!(newapi_creds(Some(&cfg(None, Some("uid")))), None); + assert_eq!(newapi_creds(Some(&cfg(Some(""), Some("uid")))), None); + assert_eq!(newapi_creds(Some(&cfg(Some("tok"), Some("")))), None); + // 两项齐全 → Some + assert_eq!( + newapi_creds(Some(&cfg(Some("tok"), Some("uid")))), + Some(("tok", "uid")) + ); + } } diff --git a/src/components/UsageConfigModal.tsx b/src/components/UsageConfigModal.tsx index ca609ec..8f65a93 100644 --- a/src/components/UsageConfigModal.tsx +++ b/src/components/UsageConfigModal.tsx @@ -4,7 +4,7 @@ import { invoke } from "@tauri-apps/api/core"; import { ArrowLeft, Eye, EyeOff, Loader2, Play } from "lucide-react"; import { useTranslation } from "../i18n"; import type { TranslationKey } from "../i18n/zh"; -import { localizeUsageError, planLabel } from "../lib/usage-display"; +import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display"; import type { Agent, Provider, UsageConfig } from "../types"; interface UsageResult { @@ -134,7 +134,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa .map((d) => { const parts: string[] = []; if (d.planName) parts.push(planLabel(d.planName, t)); - if (d.remaining != null) parts.push(`${t("usageRemaining")} ${d.remaining}${d.unit ?? ""}`); + if (d.remaining != null) parts.push(`${t("usageRemaining")} ${formatAmount(d.remaining, d.unit)}`); if (d.used != null && d.total != null && d.total > 0) parts.push(`${Math.round((d.used / d.total) * 100)}%`); return parts.join(" "); diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index 6cc2cb9..d2307cc 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Loader2, RefreshCw } from "lucide-react"; import { useTranslation } from "../i18n"; -import { localizeUsageError, planLabel } from "../lib/usage-display"; +import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display"; import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery"; interface UsageFooterProps { @@ -36,17 +36,6 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) { return null; }; - const formatAmount = ( - value: number | null | undefined, - unit?: string | null - ): string => { - if (value == null) return "—"; - const symbol = - unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit ? `${unit} ` : ""; - const num = Number.isInteger(value) ? String(value) : value.toFixed(2); - return `${symbol}${num}`; - }; - const formatReset = (resetsAt?: string | null): string | null => { if (!resetsAt) return null; const ms = new Date(resetsAt).getTime() - now; diff --git a/src/config/providerPresets.ts b/src/config/providerPresets.ts index 0308094..2fbcfd9 100644 --- a/src/config/providerPresets.ts +++ b/src/config/providerPresets.ts @@ -14,6 +14,7 @@ export type UsageKind = | "balance:stepfun" | "balance:novita" | "balance:kimi" + | "balance:newapi" | "plan:kimi_coding" | "plan:zhipu" | "plan:minimax" @@ -30,6 +31,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet = new Set([ "balance:stepfun", "balance:novita", "balance:kimi", + "balance:newapi", "plan:kimi_coding", "plan:zhipu", "plan:minimax", diff --git a/src/lib/usage-display.test.ts b/src/lib/usage-display.test.ts new file mode 100644 index 0000000..ad6012c --- /dev/null +++ b/src/lib/usage-display.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { formatAmount, localizeUsageError, planLabel } from "./usage-display"; + +// Minimal TranslateFn: return the key itself so assertions read the key. +const t = (key: string) => key; + +describe("localizeUsageError", () => { + it("maps known Rust error prefixes to their localization keys", () => { + expect( + localizeUsageError("unsupported provider: no usage query available", t) + ).toBe("usageUnsupportedProvider"); + expect(localizeUsageError("no API key configured", t)).toBe("usageErrNoKey"); + expect(localizeUsageError("usage query disabled in config panel", t)).toBe( + "usageErrDisabled" + ); + expect( + localizeUsageError("no Kimi Code OAuth credentials found; run `kimi login` first", t) + ).toBe("usageErrNoOauth"); + expect( + localizeUsageError("newapi template requires accessToken and userId", t) + ).toBe("usageErrNewapiCreds"); + expect(localizeUsageError("Authentication failed: 401 Unauthorized", t)).toBe( + "usageInvalidKey" + ); + }); + + it("login expired / refresh failed match case-insensitively anywhere", () => { + expect(localizeUsageError("LOGIN expired at 12:00", t)).toBe("usageErrLoginExpired"); + expect(localizeUsageError("oauth refresh failed: 500", t)).toBe("usageErrLoginExpired"); + }); + + it("strips the kinds-loop `{kind}: ` wrapper before matching", () => { + // commands.rs 的 kinds 循环把错误包成 "balance:newapi: ..." —— 必须仍命中。 + expect( + localizeUsageError( + "balance:newapi: newapi template requires accessToken and userId", + t + ) + ).toBe("usageErrNewapiCreds"); + expect(localizeUsageError("plan:zhipu: Authentication failed: 401", t)).toBe( + "usageInvalidKey" + ); + expect(localizeUsageError("balance:deepseek: no API key configured", t)).toBe( + "usageErrNoKey" + ); + }); + + it("passes unknown errors through unchanged (kind prefix retained)", () => { + const raw = "balance:openrouter: HTTP 429 too many requests"; + expect(localizeUsageError(raw, t)).toBe(raw); + expect(localizeUsageError("HTTP 500 Something broke", t)).toBe( + "HTTP 500 Something broke" + ); + }); +}); + +describe("planLabel", () => { + it("maps known tier ids and passes unknown names through", () => { + expect(planLabel("five_hour", t)).toBe("usageTier5h"); + expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly"); + expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly"); + expect(planLabel("NewAPI", t)).toBe("NewAPI"); + expect(planLabel("¥", t)).toBe("¥"); + }); +}); + +describe("formatAmount", () => { + it("maps CNY and USD to ¥/$ with two decimals", () => { + expect(formatAmount(1.5, "CNY")).toBe("¥1.50"); + expect(formatAmount(1.5, "USD")).toBe("$1.50"); + }); + + it("treats ¤ (U+00A4) as no configured currency symbol", () => { + expect(formatAmount(136907472.01013, "¤")).toBe("136907472.01"); + expect(formatAmount(136907472.01013, "\u{00A4}")).toBe("136907472.01"); + }); + + it("prefixes arbitrary units without a symbol", () => { + // Non-integer so the two-decimal rule applies; rounds to 3.00. + expect(formatAmount(3.004, "credit")).toBe("credit 3.00"); + }); + + it("renders null/undefined values as —", () => { + expect(formatAmount(null, "¤")).toBe("—"); + expect(formatAmount(undefined, "CNY")).toBe("—"); + }); + + it("keeps integers without decimals", () => { + expect(formatAmount(42, "CNY")).toBe("¥42"); + expect(formatAmount(100, "USD")).toBe("$100"); + expect(formatAmount(7, "")).toBe("7"); + }); +}); \ No newline at end of file diff --git a/src/lib/usage-display.ts b/src/lib/usage-display.ts index 80c8100..adf35e6 100644 --- a/src/lib/usage-display.ts +++ b/src/lib/usage-display.ts @@ -35,12 +35,32 @@ export function planLabel(name: string, t: TranslateFn): string { * src-tauri/src/commands.rs and src-tauri/src/services/*. */ export function localizeUsageError(error: string, t: TranslateFn): string { - if (error.startsWith("unsupported provider")) return t("usageUnsupportedProvider"); - if (error.startsWith("no API key configured")) return t("usageErrNoKey"); - if (error.startsWith("usage query disabled")) return t("usageErrDisabled"); - if (/login expired|refresh failed/i.test(error)) return t("usageErrLoginExpired"); - if (error.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth"); - if (error.startsWith("newapi template requires")) return t("usageErrNewapiCreds"); - if (error.startsWith("Authentication failed")) return t("usageInvalidKey"); + // commands.rs 的 kinds 循环把单条错误包成 `{kind}: {msg}`(kind 含数字, + // 如 plan:zhipu),先剥离再按前缀匹配;未知错误仍回退按原文展示(保留 kind + // 前缀,属诊断细节)。 + const stripped = error.replace(/^(balance|plan):[a-z0-9_]+:\s*/, ""); + if (stripped.startsWith("unsupported provider")) return t("usageUnsupportedProvider"); + if (stripped.startsWith("no API key configured")) return t("usageErrNoKey"); + if (stripped.startsWith("usage query disabled")) return t("usageErrDisabled"); + if (/login expired|refresh failed/i.test(stripped)) return t("usageErrLoginExpired"); + if (stripped.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth"); + if (stripped.startsWith("newapi template requires")) return t("usageErrNewapiCreds"); + if (stripped.startsWith("Authentication failed")) return t("usageInvalidKey"); return error; } + +/** + * Format a usage amount with its unit. `¤` (U+00A4) is NewAPI's placeholder + * when the site has no custom currency symbol configured — treat it as "no + * symbol" and render only the number. + */ +export function formatAmount( + value: number | null | undefined, + unit?: string | null +): string { + if (value == null) return "—"; + const symbol = + unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit && unit.trim() !== "¤" ? `${unit} ` : ""; + const num = Number.isInteger(value) ? String(value) : value.toFixed(2); + return `${symbol}${num}`; +}