diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 5528455e6..dfe8d0a0e 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -312,6 +312,12 @@ pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit"; /// 映射到 `subscription.monthly`。 pub const TIER_MONTHLY: &str = "monthly"; +/// Codex 免费方案的 30 天(月)滚动窗口 tier 名。付费方案的次要窗口是 7 天 +/// (`seven_day`),免费方案则是 30 天。由 `window_seconds_to_tier_name` 产出、 +/// tray 的月分组渲染、前端 `TIER_I18N_KEYS` 映射到 `subscription.thirtyDay` +/// 三处共用同一标识。见 #3651。 +pub const TIER_THIRTY_DAY: &str = "30_day"; + /// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。 pub const TIER_GEMINI_PRO: &str = "gemini_pro"; pub const TIER_GEMINI_FLASH: &str = "gemini_flash"; @@ -632,8 +638,12 @@ struct CodexUsageResponse { /// 根据窗口秒数映射到 tier 名称(与 Claude 的命名兼容以复用前端 i18n) fn window_seconds_to_tier_name(secs: i64) -> String { match secs { - 18000 => "five_hour".to_string(), - 604800 => "seven_day".to_string(), + 18000 => TIER_FIVE_HOUR.to_string(), + 604800 => TIER_SEVEN_DAY.to_string(), + // Codex 免费方案的 30 天窗口。显式映射到常量,与 tray 月分组、前端 + // TIER_I18N_KEYS 保持同一标识(否则动态回退虽也得到 "30_day",但字符串 + // 分散在多处、易和托盘/前端白名单脱节)。见 #3651。 + 2_592_000 => TIER_THIRTY_DAY.to_string(), s => { let hours = s / 3600; if hours >= 24 { @@ -1340,3 +1350,21 @@ fn now_millis() -> i64 { .unwrap_or_default() .as_millis() as i64 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn window_seconds_map_to_expected_tier_names() { + // 官方特例窗口 + assert_eq!(window_seconds_to_tier_name(18000), TIER_FIVE_HOUR); + assert_eq!(window_seconds_to_tier_name(604800), TIER_SEVEN_DAY); + // Codex 免费方案的次要窗口是 30 天(30 * 24 * 3600 = 2_592_000 秒)。 + // 前端 TIER_I18N_KEYS 与 tray 月分组都需要认得 "30_day",见 #3651。 + assert_eq!(window_seconds_to_tier_name(2_592_000), TIER_THIRTY_DAY); + // 其他窗口按小时/天回退命名 + assert_eq!(window_seconds_to_tier_name(3600), "1_hour"); + assert_eq!(window_seconds_to_tier_name(86400), "1_day"); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index ea614838a..0b159ae67 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -19,8 +19,13 @@ const W_TIER_NAMES: &[&str] = &[ crate::services::subscription::TIER_SEVEN_DAY_OPUS, crate::services::subscription::TIER_SEVEN_DAY_SONNET, ]; -// 火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)。 -const M_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_MONTHLY]; +// 月窗口分组:火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档), +// 以及 Codex 免费方案的 30 天窗口(#3651)——两者都归入 "m" 档,避免免费 +// Codex 账号在托盘里空白(前端 footer 能看到、托盘却不显示的不对称)。 +const M_TIER_NAMES: &[&str] = &[ + crate::services::subscription::TIER_MONTHLY, + crate::services::subscription::TIER_THIRTY_DAY, +]; const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO]; const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH]; const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] = @@ -882,7 +887,7 @@ mod tests { use crate::services::subscription::{ CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH, TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_MONTHLY, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS, - TIER_SEVEN_DAY_SONNET, TIER_WEEKLY_LIMIT, + TIER_SEVEN_DAY_SONNET, TIER_THIRTY_DAY, TIER_WEEKLY_LIMIT, }; #[test] @@ -964,6 +969,16 @@ mod tests { assert!(s.contains("l80%"), "expected l80% in {s}"); } + #[test] + fn codex_summary_thirty_day_only_still_renders() { + // Codex 免费方案的唯一 tier 是 30 天窗口。前端 footer 已能显示(TIER_I18N_KEYS + // 有 "30_day"),托盘也必须能显示——否则就是这条不变量要防的非对称:footer + // 能看到、托盘却空白。30_day 归入 "m" 月分组。见 #3651。 + let quota = make_quota("codex", true, vec![tier(TIER_THIRTY_DAY, 85.0)]); + let s = format_subscription_summary("a).expect("should format"); + assert!(s.contains("m85%"), "expected m85% in {s}"); + } + #[test] fn gemini_summary_emoji_reflects_highest_tier_including_lite() { // lite 是利用率最高的那条 → emoji 必须是红色,不能被 pro/flash 掩盖。 diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 7d46bc376..e5ce8ecaf 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -27,6 +27,8 @@ export const TIER_I18N_KEYS: Record = { seven_day: "subscription.sevenDay", seven_day_opus: "subscription.sevenDayOpus", seven_day_sonnet: "subscription.sevenDaySonnet", + // Codex 免费方案的次要窗口是 30 天(付费方案为 7 天) + "30_day": "subscription.thirtyDay", // Gemini 模型分类 gemini_pro: "subscription.geminiPro", gemini_flash: "subscription.geminiFlash", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f6d011efd..debf8bb5e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2903,6 +2903,7 @@ "sevenDay": "7-Day", "sevenDayOpus": "7-Day (Opus)", "sevenDaySonnet": "7-Day (Sonnet)", + "thirtyDay": "30-Day", "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index cee1c1f85..124005185 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2903,6 +2903,7 @@ "sevenDay": "7日間", "sevenDayOpus": "7日間(Opus)", "sevenDaySonnet": "7日間(Sonnet)", + "thirtyDay": "30日間", "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index db85f5027..0e6650b92 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2875,6 +2875,7 @@ "sevenDay": "7 天", "sevenDayOpus": "7 天 (Opus)", "sevenDaySonnet": "7 天 (Sonnet)", + "thirtyDay": "30 天", "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7e254de70..9c310a23b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2903,6 +2903,7 @@ "sevenDay": "7天", "sevenDayOpus": "7天(Opus)", "sevenDaySonnet": "7天(Sonnet)", + "thirtyDay": "30天", "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite",