mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
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:
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 Plan(five_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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -2288,6 +2288,7 @@
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "Weekly",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "Resets in {{time}}",
|
||||
"extraUsage": "Extra Usage",
|
||||
|
||||
@@ -2288,6 +2288,7 @@
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "週間",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}}後にリセット",
|
||||
"extraUsage": "超過使用量",
|
||||
|
||||
@@ -2288,6 +2288,7 @@
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "每周",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}}后重置",
|
||||
"extraUsage": "超额用量",
|
||||
|
||||
Reference in New Issue
Block a user