diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index ae051d51f..34d800c4c 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -46,6 +46,28 @@ fn millis_to_iso8601(ms: i64) -> Option { 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 { + 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 { + 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), + }); + } } } diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 10d1906d2..32cb05489 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -10,8 +10,8 @@ interface SubscriptionQuotaFooterProps { inline?: boolean; } -/** 已知 tier 名称的显示映射 */ -const TIER_I18N_KEYS: Record = { +/** 已知 tier 名称的显示映射(官方订阅 + Token Plan 共用) */ +export const TIER_I18N_KEYS: Record = { five_hour: "subscription.fiveHour", seven_day: "subscription.sevenDay", seven_day_opus: "subscription.sevenDayOpus", @@ -20,17 +20,19 @@ const TIER_I18N_KEYS: Record = { 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 = ({ }; /** inline 模式下的单个 tier 显示 */ -const TierBadge: React.FC<{ +export const TierBadge: React.FC<{ tier: QuotaTier; t: (key: string, options?: Record) => string; }> = ({ tier, t }) => { diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index d48ffe844..9b0654e52 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -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 = ({ provider, providerId, @@ -25,6 +36,8 @@ const UsageFooter: React.FC = ({ 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 = ({ // 无数据时不显示 if (usageDataList.length === 0) return null; - // 内联模式:仅显示第一个套餐的核心数据(分上下两行) + // ── Token Plan:订阅风格内联渲染(百分比徽章 + 倒计时) ── + if (isTokenPlan && inline) { + return ( +
+ {/* 第一行:查询时间 + 刷新 */} +
+ + + {lastQueriedAt + ? formatRelativeTime(lastQueriedAt, now, t) + : t("usage.never", { defaultValue: "从未更新" })} + + +
+ {/* 第二行:tier 徽章(复用官方订阅的 TierBadge) */} +
+ {usageDataList.map((data, index) => ( + + ))} +
+
+ ); + } + + // ── 通用用量:内联模式(原有逻辑) ── if (inline) { const firstUsage = usageDataList[0]; const isExpired = firstUsage.isValid === false; @@ -231,6 +278,8 @@ const UsageFooter: React.FC = ({ ); }; +// ── 通用用量组件 ──────────────────────────────────────────── + // 单个套餐数据展示组件 const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => { const { t } = useTranslation(); diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index c58d23e77..cb450fa97 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -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); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 455b33dd3..09f421fce 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2288,6 +2288,7 @@ "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", + "weeklyLimit": "Weekly", "utilization": "{{value}}%", "resetsIn": "Resets in {{time}}", "extraUsage": "Extra Usage", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 77a05fa6d..145355ef3 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2288,6 +2288,7 @@ "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", + "weeklyLimit": "週間", "utilization": "{{value}}%", "resetsIn": "{{time}}後にリセット", "extraUsage": "超過使用量", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a073f547d..75fc9232a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2288,6 +2288,7 @@ "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", + "weeklyLimit": "每周", "utilization": "{{value}}%", "resetsIn": "{{time}}后重置", "extraUsage": "超额用量",