feat: 新增 ZenMux Token Plan 供应商,支持手动凭证与 USD 额度富展示 (#2709)

* feat(Token plan): 增加 ZenMux 支持

* chore: format code with prettier

* chore: format code with cargo fmt

---------

Co-authored-by: 明桓 <jihaodong.jhd@oceanbase.com>
Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Eter
2026-06-03 14:45:49 +08:00
committed by GitHub
parent 43ae1e5f2c
commit c1dff06625
11 changed files with 438 additions and 17 deletions
@@ -309,6 +309,8 @@ export const TierBadge: React.FC<{
: tier.name;
const countdown = countdownStr(tier.resetsAt);
const hasUsd = tier.usedValueUsd != null && tier.maxValueUsd != null;
return (
<div className="flex items-center gap-0.5">
<span className="text-gray-500 dark:text-gray-400">{label}:</span>
@@ -317,6 +319,11 @@ export const TierBadge: React.FC<{
>
{t("subscription.utilization", { value: Math.round(tier.utilization) })}
</span>
{hasUsd && (
<span className="text-muted-foreground/60">
(${tier.usedValueUsd!.toFixed(2)}/${tier.maxValueUsd!.toFixed(2)})
</span>
)}
{countdown && (
<span className="text-muted-foreground/60 ml-0.5 flex items-center gap-px">
<Clock size={10} />
+33 -4
View File
@@ -19,10 +19,26 @@ interface UsageFooterProps {
/** UsageData → QuotaTier 转换(Token Plan 使用) */
function toQuotaTier(data: UsageData): QuotaTier {
const extra = data.extra;
if (extra && extra.startsWith("{")) {
try {
const parsed = JSON.parse(extra);
return {
name: data.planName || "",
utilization: data.used || 0,
resetsAt: parsed.resetsAt || null,
usedValueUsd: parsed.usedValueUsd ?? null,
maxValueUsd: parsed.maxValueUsd ?? null,
planLabel: parsed.planLabel ?? null,
};
} catch {
// fall through to plain string
}
}
return {
name: data.planName || "",
utilization: data.used || 0,
resetsAt: data.extra || null,
resetsAt: extra || null,
};
}
@@ -147,9 +163,22 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
</div>
{/* 第二行:tier 徽章(复用官方订阅的 TierBadge) */}
<div className="flex items-center gap-2">
{usageDataList.map((data, index) => (
<TierBadge key={index} tier={toQuotaTier(data)} t={t} />
))}
{(() => {
const tiers = usageDataList.map((d) => toQuotaTier(d));
const planLabel = tiers[0]?.planLabel;
return (
<>
{planLabel && (
<span className="font-semibold text-muted-foreground">
💰 {planLabel}
</span>
)}
{tiers.map((tier, index) => (
<TierBadge key={index} tier={tier} t={t} />
))}
</>
);
})()}
</div>
</div>
);
+77 -7
View File
@@ -444,8 +444,14 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
// Coding Plan 模板使用专用 API
if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) {
const baseUrl = providerCredentials.baseUrl ?? "";
const apiKey = providerCredentials.apiKey ?? "";
// ZenMux 使用用户在脚本配置中手动填入的 API Key 和 Base URL
const isZenMux = script.codingPlanProvider === "zenmux";
const baseUrl = isZenMux
? (script.baseUrl ?? "")
: (providerCredentials.baseUrl ?? "");
const apiKey = isZenMux
? (script.apiKey ?? "")
: (providerCredentials.apiKey ?? "");
const { subscriptionApi } = await import("@/lib/api/subscription");
const quota = await subscriptionApi.getCodingPlanQuota(baseUrl, apiKey);
if (quota.success && quota.tiers.length > 0) {
@@ -626,15 +632,17 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const autoDetected = detectCodingPlanProvider(
providerCredentials.baseUrl,
);
const provider = script.codingPlanProvider || autoDetected || "kimi";
// ZenMux 允许手动填写 API Key 和 Base URL,不清除
const isZenMux = provider === "zenmux";
setScript({
...script,
code: "",
apiKey: undefined,
baseUrl: undefined,
apiKey: isZenMux ? script.apiKey : undefined,
baseUrl: isZenMux ? script.baseUrl : undefined,
accessToken: undefined,
userId: undefined,
codingPlanProvider:
script.codingPlanProvider || autoDetected || "kimi",
codingPlanProvider: provider,
});
} else if (presetName === TEMPLATE_TYPES.BALANCE) {
// 官方余额查询模板不需要脚本,使用 Rust 原生查询
@@ -653,7 +661,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const shouldShowCredentialsConfig =
selectedTemplate === TEMPLATE_TYPES.GENERAL ||
selectedTemplate === TEMPLATE_TYPES.NEW_API;
selectedTemplate === TEMPLATE_TYPES.NEW_API ||
(selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN &&
script.codingPlanProvider === "zenmux");
const footer = (
<>
@@ -1050,6 +1060,66 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
</>
)}
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN &&
script.codingPlanProvider === "zenmux" && (
<>
<div className="space-y-2">
<Label htmlFor="usage-zenmux-base-url">
{t("usageScript.baseUrl")}
</Label>
<Input
id="usage-zenmux-base-url"
type="text"
value={script.baseUrl || ""}
onChange={(e) =>
setScript({ ...script, baseUrl: e.target.value })
}
placeholder="https://api.zenmux.com/v1/..."
autoComplete="off"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-zenmux-api-key">API Key</Label>
<div className="relative">
<Input
id="usage-zenmux-api-key"
type={showApiKey ? "text" : "password"}
value={script.apiKey || ""}
onChange={(e) =>
setScript({
...script,
apiKey: e.target.value,
})
}
placeholder="sk-..."
autoComplete="off"
className="border-white/10"
/>
{script.apiKey && (
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
showApiKey
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showApiKey ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
)}
</div>
</div>
</>
)}
</div>
</div>
)}
+6 -1
View File
@@ -11,7 +11,7 @@ import { TEMPLATE_TYPES } from "@/config/constants";
export interface CodingPlanProviderEntry {
/** 与后端 QuotaTier 的 `codingPlanProvider` 取值对齐 */
id: "kimi" | "zhipu" | "minimax";
id: "kimi" | "zhipu" | "minimax" | "zenmux";
/** UsageScriptModal 下拉显示用 */
label: string;
/** base_url 匹配规则 */
@@ -30,6 +30,11 @@ export const CODING_PLAN_PROVIDERS: readonly CodingPlanProviderEntry[] = [
label: "MiniMax",
pattern: /api\.minimaxi?\.com|api\.minimax\.io/i,
},
{
id: "zenmux",
label: "ZenMux",
pattern: /zenmux\./i,
},
] as const;
/** 根据 Base URL 自动检测 Coding Plan 供应商;未命中返回 null */
File diff suppressed because one or more lines are too long
+7
View File
@@ -306,6 +306,13 @@ export const iconMetadata: Record<string, IconMetadata> = {
keywords: ["minimax"],
defaultColor: "#FF6B6B",
},
zenmux: {
name: "zenmux",
displayName: "ZenMux",
category: "ai-provider",
keywords: ["zenmux", "zen", "mux"],
defaultColor: "#6366F1",
},
mistral: {
name: "mistral",
displayName: "Mistral",
+3
View File
@@ -8,6 +8,9 @@ export interface QuotaTier {
name: string;
utilization: number; // 0-100
resetsAt: string | null;
usedValueUsd?: number | null;
maxValueUsd?: number | null;
planLabel?: string | null;
}
export interface ExtraUsage {