feat(usage): NewAPI 订阅额度行 + 修复配置面板错误百分比(v0.8.4)
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
- NewAPI 用量查询新增 /api/subscription/self:订阅额度占比作为独立一行展示, 与钱包余额(/api/user/self)分开——两者是互相独立的账本 - 多订阅全部展示;订阅行 unit=% 自动参与预警阈值 - 老版本站点(无订阅接口 404)或无订阅时静默降级,行为与 v0.8.3 一致 - 修复配置面板「测试查询」对金额行错算百分比(曾把钱包余额显示为「已用 29%」); 抽出 isMoneyRow/isPlanRow/percentOf 到 usage-display.ts 与卡片共用 - models.dev 快照同步至 2026-10-15(8,876 模型 / 230 供应商) - 测试:Rust 205(+5)、前端 150(+4),tsc 零错误
This commit is contained in:
1 parent
f643802f82
commit
e00f6b6591
17 files changed
+21307
-3515
No files matched your search
@@ -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 { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import { formatAmount, isPlanRow, localizeUsageError, percentOf, planLabel } from "../lib/usage-display";
|
||||
import type { Agent, Provider, UsageConfig } from "../types";
|
||||
|
||||
interface UsageResult {
|
||||
@@ -131,13 +131,18 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
||||
});
|
||||
if (result.success) {
|
||||
setTestOk(true);
|
||||
// 与卡片 UsageFooter 同一套行判定:金额行(NewAPI 钱包 / 加力钱包)只显示
|
||||
// 金额——其 used/total 是钱数,算成「百分比」是误导(曾把钱包余额误显示为
|
||||
// 「已用 29%」)。只有真正的配额行才追加百分比。
|
||||
const summary = (result.data ?? [])
|
||||
.map((d) => {
|
||||
const parts: string[] = [];
|
||||
if (d.planName) parts.push(planLabel(d.planName, t));
|
||||
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)}%`);
|
||||
if (isPlanRow(d)) {
|
||||
const pct = percentOf(d);
|
||||
if (pct != null) parts.push(`${Math.round(pct)}%`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -2,13 +2,14 @@ import { useEffect, useState } from "react";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import {
|
||||
BOOSTER_WALLET_PLAN,
|
||||
formatAmount,
|
||||
isPlanRow,
|
||||
localizeUsageError,
|
||||
percentOf,
|
||||
planLabel,
|
||||
} from "../lib/usage-display";
|
||||
import { isAlertTier } from "../hooks/useUsageQuery";
|
||||
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
|
||||
import type { UsageQueryState } from "../hooks/useUsageQuery";
|
||||
|
||||
interface UsageFooterProps {
|
||||
/** Query state lifted to the provider card via useUsageQuery. Both the
|
||||
@@ -21,18 +22,15 @@ interface UsageFooterProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额行 vs 配额行:NewAPI 与 Kimi 加力钱包(booster_wallet)只报金额,其
|
||||
* used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看
|
||||
* 金额行 vs 配额行:NewAPI 的钱包行与 Kimi 加力钱包(booster_wallet)只报金额,
|
||||
* 其 used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看
|
||||
* planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义仍是「额度用了百分之
|
||||
* 多少」,按 unit 判定会把它误渲染成余额。
|
||||
*
|
||||
* NewAPI 站点可同时返回钱包行(planName "NewAPI",金额)与订阅行
|
||||
* (planName "subscription",`unit="%"` 的配额占比);后者不属于金额行,走
|
||||
* 百分比渲染路径。判定逻辑与 usage-display.ts 共用,避免与配置面板再次分叉。
|
||||
*/
|
||||
function isMoneyRow(d: UsageData): boolean {
|
||||
return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN;
|
||||
}
|
||||
|
||||
function isPlanRow(d: UsageData): boolean {
|
||||
return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null);
|
||||
}
|
||||
|
||||
export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -48,14 +46,6 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
|
||||
|
||||
if (!supported || status === "idle") return null;
|
||||
|
||||
const percentOf = (d: UsageData): number | null => {
|
||||
if (d.used != null && d.total != null && d.total > 0)
|
||||
return (d.used / d.total) * 100;
|
||||
if (d.remaining != null && d.total != null && d.total > 0)
|
||||
return (1 - d.remaining / d.total) * 100;
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatReset = (resetsAt?: string | null): string | null => {
|
||||
if (!resetsAt) return null;
|
||||
const ms = new Date(resetsAt).getTime() - now;
|
||||
|
||||
@@ -514,6 +514,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
usageTierMonthly: "Monthly",
|
||||
usageTierMonthCode: "Monthly (code)",
|
||||
usageTierBoosterWallet: "Booster",
|
||||
usageTierSubscription: "Subscription",
|
||||
usageThreshold: "Alert threshold",
|
||||
usageThresholdHint: "Highlight any window that reaches this percentage; 0 = off",
|
||||
usageThresholdAlert: "Threshold reached",
|
||||
|
||||
@@ -503,6 +503,7 @@ export const zhTranslations = {
|
||||
usageTierMonthly: "30天",
|
||||
usageTierMonthCode: "30天(代码)",
|
||||
usageTierBoosterWallet: "加力钱包",
|
||||
usageTierSubscription: "订阅",
|
||||
usageThreshold: "预警阈值",
|
||||
usageThresholdHint: "任一用量窗口达到该百分比时高亮提醒,0 为关闭",
|
||||
usageThresholdAlert: "已达预警阈值",
|
||||
|
||||
+7385
-1206
File diff suppressed because it is too large.
Load diff
+6792
-1138
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAmount, localizeUsageError, planLabel } from "./usage-display";
|
||||
import {
|
||||
formatAmount,
|
||||
isMoneyRow,
|
||||
isPlanRow,
|
||||
localizeUsageError,
|
||||
percentOf,
|
||||
planLabel,
|
||||
} from "./usage-display";
|
||||
|
||||
// Minimal TranslateFn: return the key itself so assertions read the key.
|
||||
const t = (key: string) => key;
|
||||
@@ -60,11 +67,51 @@ describe("planLabel", () => {
|
||||
expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly");
|
||||
expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly");
|
||||
expect(planLabel("booster_wallet", t)).toBe("usageTierBoosterWallet");
|
||||
expect(planLabel("subscription", t)).toBe("usageTierSubscription");
|
||||
expect(planLabel("NewAPI", t)).toBe("NewAPI");
|
||||
expect(planLabel("¥", t)).toBe("¥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMoneyRow / isPlanRow", () => {
|
||||
it("treats the NewAPI wallet and booster wallet as money rows", () => {
|
||||
// NewAPI 钱包行只报金额,其 used/total 是钱数,不能当百分比渲染。
|
||||
expect(isMoneyRow({ planName: "NewAPI" })).toBe(true);
|
||||
expect(isMoneyRow({ planName: "booster_wallet" })).toBe(true);
|
||||
expect(isMoneyRow({ planName: "subscription" })).toBe(false);
|
||||
expect(isMoneyRow({ planName: "weekly_limit" })).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies quota rows by planName, not unit", () => {
|
||||
// NewAPI 订阅行:unit="%" → 配额行
|
||||
expect(
|
||||
isPlanRow({ planName: "subscription", total: 100, used: 41 })
|
||||
).toBe(true);
|
||||
// sub2api 订阅窗口:unit 是 USD,但语义仍是配额占比 → 仍算配额行
|
||||
expect(
|
||||
isPlanRow({ planName: "weekly_limit", total: 50, used: 10 })
|
||||
).toBe(true);
|
||||
// 钱包行永远不是配额行,即使带了 total/used
|
||||
expect(isPlanRow({ planName: "NewAPI", total: 704, used: 204 })).toBe(false);
|
||||
// 无 planName 或无数值 → 非配额行
|
||||
expect(isPlanRow({ planName: null, total: 100, used: 1 })).toBe(false);
|
||||
expect(isPlanRow({ planName: "x" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("percentOf", () => {
|
||||
it("prefers used/total and falls back to remaining/total", () => {
|
||||
expect(percentOf({ used: 41, total: 100 })).toBe(41);
|
||||
expect(percentOf({ remaining: 59, total: 100 })).toBe(41);
|
||||
});
|
||||
|
||||
it("returns null when total is missing or non-positive", () => {
|
||||
expect(percentOf({ used: 41 })).toBeNull();
|
||||
expect(percentOf({ used: 41, total: 0 })).toBeNull();
|
||||
expect(percentOf({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAmount", () => {
|
||||
it("maps CNY and USD to ¥/$ with two decimals", () => {
|
||||
expect(formatAmount(1.5, "CNY")).toBe("¥1.50");
|
||||
|
||||
@@ -12,6 +12,44 @@ type TranslateFn = (
|
||||
*/
|
||||
export const BOOSTER_WALLET_PLAN = "booster_wallet";
|
||||
|
||||
/**
|
||||
* 金额行 vs 配额行:NewAPI 的钱包行与 Kimi 加力钱包(booster_wallet)只报金额,
|
||||
* 其 used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。
|
||||
*
|
||||
* 注意 NewAPI 站点同时有钱包与订阅两套账:钱包行的 planName 是 "NewAPI"(金额),
|
||||
* 订阅行是 "subscription"(`unit="%"` 的配额占比),后者不在此列表内,正常走
|
||||
* 百分比渲染。判定只能看 planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义
|
||||
* 仍是「额度用了百分之多少」,按 unit 判定会把它误渲染成余额。
|
||||
*/
|
||||
export function isMoneyRow(d: { planName?: string | null }): boolean {
|
||||
return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN;
|
||||
}
|
||||
|
||||
/** 额度/配额行:有 planName、非金额行,且报了 total 或 used。 */
|
||||
export function isPlanRow(d: {
|
||||
planName?: string | null;
|
||||
total?: number | null;
|
||||
used?: number | null;
|
||||
}): boolean {
|
||||
return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配额行的「已用百分比」。优先 used/total,其次由 remaining/total 反推;
|
||||
* total 缺失或 ≤0(无法算比例)返回 null。金额行不应调用此函数。
|
||||
*/
|
||||
export function percentOf(d: {
|
||||
used?: number | null;
|
||||
total?: number | null;
|
||||
remaining?: number | null;
|
||||
}): number | null {
|
||||
if (d.used != null && d.total != null && d.total > 0)
|
||||
return (d.used / d.total) * 100;
|
||||
if (d.remaining != null && d.total != null && d.total > 0)
|
||||
return (1 - d.remaining / d.total) * 100;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw Rust plan/tier name to a localized label. Unknown names (proper
|
||||
* nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged.
|
||||
@@ -19,9 +57,10 @@ export const BOOSTER_WALLET_PLAN = "booster_wallet";
|
||||
* Coverage note: ALL plan-type (套餐) queries flow through
|
||||
* src-tauri/src/services/coding_plan.rs::percent_tier, whose names come only
|
||||
* from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT / TIER_MONTHLY_LIMIT /
|
||||
* TIER_MONTH_CODE — so this mapping covers every current and future plan
|
||||
* provider automatically. If a new tier id is ever added there, add a case
|
||||
* here too.
|
||||
* TIER_MONTH_CODE — so the fixed tiers are covered here. The other cases come
|
||||
* from src-tauri/src/services/balance.rs: `booster_wallet` (Kimi 加力钱包) and
|
||||
* `subscription` (NewAPI 订阅套餐额度行). If a new tier id is added anywhere,
|
||||
* add a case here too.
|
||||
*/
|
||||
export function planLabel(name: string, t: TranslateFn): string {
|
||||
switch (name) {
|
||||
@@ -37,6 +76,8 @@ export function planLabel(name: string, t: TranslateFn): string {
|
||||
return t("usageTierMonthCode");
|
||||
case BOOSTER_WALLET_PLAN:
|
||||
return t("usageTierBoosterWallet");
|
||||
case "subscription":
|
||||
return t("usageTierSubscription");
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user