fix: correct MiniMax quota calculation and improve Token Plan display

- Fix MiniMax usage_count being treated as remaining (was inverted)
- Add MiniMax weekly quota tier extraction
- Remove Zhipu TIME_LIMIT (tools usage), keep only TOKENS_LIMIT
- Improve Kimi parsing with extract_reset_time and parse_f64 helpers
- Reuse TierBadge for Token Plan inline rendering
- Clean up unused i18n keys and debug println
This commit is contained in:
Jason
2026-04-05 13:35:54 +08:00
Unverified
parent bfdac2a22a
commit ca6a187745
7 changed files with 146 additions and 64 deletions
+83 -57
View File
@@ -46,6 +46,28 @@ fn millis_to_iso8601(ms: i64) -> Option<String> {
chrono::DateTime::from_timestamp(secs, nsecs).map(|dt| dt.to_rfc3339())
}
/// 从 JSON 值提取重置时间,兼容字符串和数字格式
/// - 字符串:直接返回(ISO 8601)
/// - 数字:自动判断秒/毫秒并转为 ISO 8601
fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
if let Some(s) = value.as_str() {
return Some(s.to_string());
}
if let Some(n) = value.as_i64() {
// 区分秒和毫秒:秒级时间戳 < 1e12,毫秒 >= 1e12
let ms = if n < 1_000_000_000_000 { n * 1000 } else { n };
return millis_to_iso8601(ms);
}
None
}
/// 解析 JSON 值为 f64,兼容数字和字符串格式(如 `100` 和 `"100"`
fn parse_f64(value: &serde_json::Value) -> Option<f64> {
value
.as_f64()
.or_else(|| value.as_str().and_then(|s| s.parse().ok()))
}
fn make_error(msg: String) -> SubscriptionQuota {
SubscriptionQuota {
tool: "coding_plan".to_string(),
@@ -103,16 +125,36 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
let mut tiers = Vec::new();
// 5 小时窗口限额(优先显示)
if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) {
for limit_item in limits {
if let Some(detail) = limit_item.get("detail") {
let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0);
let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0);
let resets_at = detail.get("resetTime").and_then(extract_reset_time);
let used = (limit - remaining).max(0.0);
let utilization = if limit > 0.0 {
(used / limit) * 100.0
} else {
0.0
};
tiers.push(QuotaTier {
name: "five_hour".to_string(),
utilization,
resets_at,
});
}
}
}
// 总体用量(周限额)
if let Some(usage) = body.get("usage") {
let used = usage.get("used").and_then(|v| v.as_f64()).unwrap_or(0.0);
let limit = usage.get("limit").and_then(|v| v.as_f64()).unwrap_or(1.0);
let resets_at = usage
.get("reset_at")
.or_else(|| usage.get("resetAt"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let limit = usage.get("limit").and_then(parse_f64).unwrap_or(1.0);
let remaining = usage.get("remaining").and_then(parse_f64).unwrap_or(0.0);
let resets_at = usage.get("resetTime").and_then(extract_reset_time);
let used = (limit - remaining).max(0.0);
let utilization = if limit > 0.0 {
(used / limit) * 100.0
} else {
@@ -125,32 +167,6 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
});
}
// 会话限额(5 小时窗口)
if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) {
for limit_item in limits {
if let Some(detail) = limit_item.get("detail") {
let used = detail.get("used").and_then(|v| v.as_f64()).unwrap_or(0.0);
let limit = detail.get("limit").and_then(|v| v.as_f64()).unwrap_or(1.0);
let resets_at = detail
.get("reset_at")
.or_else(|| detail.get("resetAt"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let utilization = if limit > 0.0 {
(used / limit) * 100.0
} else {
0.0
};
tiers.push(QuotaTier {
name: "session_limit".to_string(),
utilization,
resets_at,
});
}
}
}
SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Valid,
@@ -238,14 +254,12 @@ async fn query_zhipu(api_key: &str) -> SubscriptionQuota {
.and_then(|v| v.as_i64())
.and_then(millis_to_iso8601);
let tier_name = match limit_type {
"TOKENS_LIMIT" => "tokens_limit",
"TIME_LIMIT" => "mcp_limit",
_ => continue,
};
if limit_type != "TOKENS_LIMIT" {
continue;
}
tiers.push(QuotaTier {
name: tier_name.to_string(),
name: "five_hour".to_string(),
utilization: percentage,
resets_at: next_reset,
});
@@ -337,33 +351,45 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
let mut tiers = Vec::new();
if let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) {
for item in model_remains {
let model_name = item
.get("model_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let total = item
// 只取第一个模型(MiniMax-M*,主力编程模型)
if let Some(item) = model_remains.first() {
// 窗口额度(current_interval_usage_count = 已用量,非剩余量)
let interval_total = item
.get("current_interval_total_count")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
// 注意:current_interval_usage_count 名字有误导,实际是"剩余量"
let remaining = item
let interval_used = item
.get("current_interval_usage_count")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let end_time = item.get("end_time").and_then(|v| v.as_i64());
let utilization = if total > 0.0 {
((total - remaining) / total) * 100.0
} else {
0.0
};
if interval_total > 0.0 {
tiers.push(QuotaTier {
name: "five_hour".to_string(),
utilization: (interval_used / interval_total) * 100.0,
resets_at: end_time.and_then(millis_to_iso8601),
});
}
tiers.push(QuotaTier {
name: model_name.to_string(),
utilization,
resets_at: end_time.and_then(millis_to_iso8601),
});
// 周额度
let weekly_total = item
.get("current_weekly_total_count")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let weekly_used = item
.get("current_weekly_usage_count")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let weekly_end = item.get("weekly_end_time").and_then(|v| v.as_i64());
if weekly_total > 0.0 {
tiers.push(QuotaTier {
name: "weekly_limit".to_string(),
utilization: (weekly_used / weekly_total) * 100.0,
resets_at: weekly_end.and_then(millis_to_iso8601),
});
}
}
}
+7 -5
View File
@@ -10,8 +10,8 @@ interface SubscriptionQuotaFooterProps {
inline?: boolean;
}
/** 已知 tier 名称的显示映射 */
const TIER_I18N_KEYS: Record<string, string> = {
/** 已知 tier 名称的显示映射(官方订阅 + Token Plan 共用) */
export const TIER_I18N_KEYS: Record<string, string> = {
five_hour: "subscription.fiveHour",
seven_day: "subscription.sevenDay",
seven_day_opus: "subscription.sevenDayOpus",
@@ -20,17 +20,19 @@ const TIER_I18N_KEYS: Record<string, string> = {
gemini_pro: "subscription.geminiPro",
gemini_flash: "subscription.geminiFlash",
gemini_flash_lite: "subscription.geminiFlashLite",
// Token Planfive_hour 已在上方官方映射中)
weekly_limit: "subscription.weeklyLimit",
};
/** 根据使用百分比返回颜色 class */
function utilizationColor(utilization: number): string {
export function utilizationColor(utilization: number): string {
if (utilization >= 90) return "text-red-500 dark:text-red-400";
if (utilization >= 70) return "text-orange-500 dark:text-orange-400";
return "text-green-600 dark:text-green-400";
}
/** 计算倒计时的纯时间字符串,如 "2h30m"、"3d12h" */
function countdownStr(resetsAt: string | null): string | null {
export function countdownStr(resetsAt: string | null): string | null {
if (!resetsAt) return null;
const diffMs = new Date(resetsAt).getTime() - Date.now();
if (diffMs <= 0) return null;
@@ -278,7 +280,7 @@ const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
};
/** inline 模式下的单个 tier 显示 */
const TierBadge: React.FC<{
export const TierBadge: React.FC<{
tier: QuotaTier;
t: (key: string, options?: Record<string, unknown>) => string;
}> = ({ tier, t }) => {
+50 -1
View File
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
import { type AppId } from "@/lib/api";
import { useUsageQuery } from "@/lib/query/queries";
import { UsageData, Provider } from "@/types";
import { TierBadge } from "@/components/SubscriptionQuotaFooter";
import type { QuotaTier } from "@/types/subscription";
interface UsageFooterProps {
provider: Provider;
@@ -15,6 +17,15 @@ interface UsageFooterProps {
inline?: boolean; // 是否内联显示(在按钮左侧)
}
/** UsageData → QuotaTier 转换(Token Plan 使用) */
function toQuotaTier(data: UsageData): QuotaTier {
return {
name: data.planName || "",
utilization: data.used || 0,
resetsAt: data.extra || null,
};
}
const UsageFooter: React.FC<UsageFooterProps> = ({
provider,
providerId,
@@ -25,6 +36,8 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
inline = false,
}) => {
const { t } = useTranslation();
const isTokenPlan =
provider.meta?.usage_script?.templateType === "token_plan";
// 统一的用量查询(自动查询仅对当前激活的供应商启用)
// OpenCode(累加模式):使用 isInConfig 代替 isCurrent
@@ -108,7 +121,41 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
// 无数据时不显示
if (usageDataList.length === 0) return null;
// 内联模式:仅显示第一个套餐的核心数据(分上下两行)
// ── Token Plan:订阅风格内联渲染(百分比徽章 + 倒计时) ──
if (isTokenPlan && inline) {
return (
<div className="flex flex-col items-end gap-1 text-xs whitespace-nowrap flex-shrink-0">
{/* 第一行:查询时间 + 刷新 */}
<div className="flex items-center gap-2 justify-end">
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<Clock size={10} />
{lastQueriedAt
? formatRelativeTime(lastQueriedAt, now, t)
: t("usage.never", { defaultValue: "从未更新" })}
</span>
<button
onClick={(e) => {
e.stopPropagation();
refetch();
}}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground"
title={t("usage.refreshUsage")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
{/* 第二行:tier 徽章(复用官方订阅的 TierBadge) */}
<div className="flex items-center gap-2">
{usageDataList.map((data, index) => (
<TierBadge key={index} tier={toQuotaTier(data)} t={t} />
))}
</div>
</div>
);
}
// ── 通用用量:内联模式(原有逻辑) ──
if (inline) {
const firstUsage = usageDataList[0];
const isExpired = firstUsage.isValid === false;
@@ -231,6 +278,8 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
);
};
// ── 通用用量组件 ────────────────────────────────────────────
// 单个套餐数据展示组件
const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => {
const { t } = useTranslation();
+3 -1
View File
@@ -186,8 +186,10 @@ export function ProviderCard({
autoQueryInterval,
});
const isTokenPlan =
provider.meta?.usage_script?.templateType === "token_plan";
const hasMultiplePlans =
usage?.success && usage.data && usage.data.length > 1;
usage?.success && usage.data && usage.data.length > 1 && !isTokenPlan;
const [isExpanded, setIsExpanded] = useState(false);
+1
View File
@@ -2288,6 +2288,7 @@
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "Weekly",
"utilization": "{{value}}%",
"resetsIn": "Resets in {{time}}",
"extraUsage": "Extra Usage",
+1
View File
@@ -2288,6 +2288,7 @@
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "週間",
"utilization": "{{value}}%",
"resetsIn": "{{time}}後にリセット",
"extraUsage": "超過使用量",
+1
View File
@@ -2288,6 +2288,7 @@
"geminiPro": "Pro",
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "每周",
"utilization": "{{value}}%",
"resetsIn": "{{time}}后重置",
"extraUsage": "超额用量",