diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index d0dd4043c..8c726af08 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -15,6 +15,7 @@ use std::str::FromStr; const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot"; const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan"; const TEMPLATE_TYPE_BALANCE: &str = "balance"; +const TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION: &str = "official_subscription"; const COPILOT_UNIT_PREMIUM: &str = "requests"; /// 获取所有供应商 @@ -596,6 +597,50 @@ async fn query_provider_usage_inner( .map_err(|e| format!("Failed to query balance: {e}")); } + // ── 官方订阅额度查询路径 ── + if template_type == TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION { + if !usage_script.map(|s| s.enabled).unwrap_or(false) { + return Ok(crate::provider::UsageResult { + success: false, + data: None, + error: Some("Usage query is disabled".to_string()), + }); + } + + let quota = crate::services::subscription::get_subscription_quota(app_type.as_str()) + .await + .map_err(|e| format!("Failed to query subscription quota: {e}"))?; + + if !quota.success { + return Ok(crate::provider::UsageResult { + success: false, + data: None, + error: quota.error.or(quota.credential_message), + }); + } + + let data: Vec = quota + .tiers + .iter() + .map(|tier| crate::provider::UsageData { + plan_name: Some(tier.name.clone()), + remaining: Some(100.0 - tier.utilization), + total: Some(100.0), + used: Some(tier.utilization), + unit: Some("%".to_string()), + is_valid: Some(true), + invalid_message: None, + extra: tier.resets_at.clone(), + }) + .collect(); + + return Ok(crate::provider::UsageResult { + success: true, + data: if data.is_empty() { None } else { Some(data) }, + error: None, + }); + } + // ── 通用 JS 脚本路径 ── ProviderService::query_usage(state, app_type, provider_id) .await diff --git a/src-tauri/src/services/usage_cache.rs b/src-tauri/src/services/usage_cache.rs index 638040fca..b9eed8adc 100644 --- a/src-tauri/src/services/usage_cache.rs +++ b/src-tauri/src/services/usage_cache.rs @@ -69,6 +69,19 @@ impl UsageCache { w.remove(&key); } } + + pub fn invalidate_subscription(&self, app_type: &AppType) { + if !self + .subscription + .read() + .is_ok_and(|r| r.contains_key(app_type)) + { + return; + } + if let Ok(mut w) = self.subscription.write() { + w.remove(app_type); + } + } } #[cfg(test)] diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 1c30145cf..61c2aa91e 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -11,6 +11,26 @@ use crate::app_config::AppType; use crate::error::AppError; use crate::store::AppState; +const TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION: &str = "official_subscription"; +const H_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_FIVE_HOUR]; +const W_TIER_NAMES: &[&str] = &[ + crate::services::subscription::TIER_WEEKLY_LIMIT, + crate::services::subscription::TIER_SEVEN_DAY, + crate::services::subscription::TIER_SEVEN_DAY_OPUS, + crate::services::subscription::TIER_SEVEN_DAY_SONNET, +]; +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] = + &[crate::services::subscription::TIER_GEMINI_FLASH_LITE]; +const TIER_LABEL_GROUPS: &[(&str, &[&str])] = &[ + ("h", H_TIER_NAMES), + ("w", W_TIER_NAMES), + ("p", GEMINI_PRO_TIER_NAMES), + ("f", GEMINI_FLASH_TIER_NAMES), + ("l", GEMINI_FLASH_LITE_TIER_NAMES), +]; + /// 每个 app 分区的子菜单句柄,用于 usage 更新时就地改 label 而非整菜单重建。 /// `create_tray_menu` 每次重建都会整表覆盖写入,保证句柄始终指向当前活跃菜单。 static TRAY_SECTION_SUBMENUS: Lazy< @@ -121,47 +141,16 @@ fn emoji_for_utilization(pct: f64) -> &'static str { fn format_subscription_summary( quota: &crate::services::subscription::SubscriptionQuota, ) -> Option { - use crate::services::subscription::{ - TIER_FIVE_HOUR, TIER_GEMINI_FLASH, TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_SEVEN_DAY, - }; if !quota.success { return None; } - // 按 tool 选取主卡槽 tier 并映射到短 label: - // Claude / Codex 沿用时间窗口(h=5 小时,w=7 天); - // Gemini 用模型维度(p=pro,f=flash,l=flash-lite)——Gemini 后端 tier - // 命名是 gemini_pro / gemini_flash / gemini_flash_lite,与时间窗口不同命名空间。 - // flash_lite 必须纳入:否则 lite 利用率最高时色标偏低,与前端 footer 行为不一致。 - let parts: Vec<(&'static str, f64)> = match quota.tool.as_str() { - "gemini" => { - let mut v = Vec::new(); - if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_GEMINI_PRO) { - v.push(("p", t.utilization)); - } - if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_GEMINI_FLASH) { - v.push(("f", t.utilization)); - } - if let Some(t) = quota - .tiers - .iter() - .find(|t| t.name == TIER_GEMINI_FLASH_LITE) - { - v.push(("l", t.utilization)); - } - v - } - _ => { - let mut v = Vec::new(); - if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_FIVE_HOUR) { - v.push(("h", t.utilization)); - } - if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_SEVEN_DAY) { - v.push(("w", t.utilization)); - } - v - } - }; + let entries: Vec<(&str, f64)> = quota + .tiers + .iter() + .map(|tier| (tier.name.as_str(), tier.utilization)) + .collect(); + let parts = labeled_tier_parts(&entries); if parts.is_empty() { return None; @@ -185,6 +174,22 @@ fn format_subscription_summary( Some(format!("{emoji} {body}")) } +fn labeled_tier_parts(entries: &[(&str, f64)]) -> Vec<(&'static str, f64)> { + let mut parts = Vec::new(); + for &(label, tier_names) in TIER_LABEL_GROUPS { + let max_utilization = entries + .iter() + .filter(|(name, _)| tier_names.contains(name)) + .map(|(_, utilization)| *utilization) + .filter(|utilization| utilization.is_finite()) + .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + if let Some(utilization) = max_utilization { + parts.push((label, utilization)); + } + } + parts +} + fn tier_pct(data: &crate::provider::UsageData) -> Option { match (data.used, data.total) { (Some(used), Some(total)) if total > 0.0 => Some(used / total * 100.0), @@ -193,8 +198,6 @@ fn tier_pct(data: &crate::provider::UsageData) -> Option { } fn format_script_summary(result: &crate::provider::UsageResult) -> Option { - use crate::services::subscription::{TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT}; - if !result.success { return None; } @@ -203,23 +206,15 @@ fn format_script_summary(result: &crate::provider::UsageResult) -> Option = Vec::new(); - for &(tier_name, label) in TOKEN_PLAN_LABELS { - let Some(d) = data - .iter() - .find(|d| d.plan_name.as_deref() == Some(tier_name)) - else { - continue; - }; - if let Some(u) = tier_pct(d) { - parts.push((label, u)); - } - } + // commands::provider 的 token_plan / official_subscription 分支都会把 + // SubscriptionQuota 的每个 tier 扁平化为一条 UsageData(plan_name 承载 + // tier 名),所以这里按 plan_name 恢复托盘短标签。其余 usage 结果 + //(Copilot / balance / 自定义脚本)走 fallback。 + let entries: Vec<(&str, f64)> = data + .iter() + .filter_map(|d| Some((d.plan_name.as_deref()?, tier_pct(d)?))) + .collect(); + let parts = labeled_tier_parts(&entries); if !parts.is_empty() { let worst = parts .iter() @@ -246,6 +241,18 @@ fn format_script_summary(result: &crate::provider::UsageResult) -> Option bool { + provider + .meta + .as_ref() + .and_then(|m| m.usage_script.as_ref()) + .map(|script| { + script.enabled + && script.template_type.as_deref() == Some(TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION) + }) + .unwrap_or(false) +} + fn format_usage_suffix( app_state: &AppState, app_type: &AppType, @@ -254,7 +261,10 @@ fn format_usage_suffix( ) -> Option { // 当前脚本是否启用:禁用/删除时不再沿用旧 UsageCache 结果, // 并顺手 invalidate,防止后续重建继续命中过期数据。 - if provider.has_usage_script_enabled() { + let is_official_provider = provider.category.as_deref() == Some("official"); + let can_use_script = provider.has_usage_script_enabled() + && (!is_official_provider || provider_uses_official_subscription(provider)); + if can_use_script { // 脚本缓存优先(覆盖 Copilot/coding_plan/balance/自定义脚本),借用访问避免克隆整条 UsageResult。 if let Some(Some(s)) = app_state @@ -263,19 +273,22 @@ fn format_usage_suffix( { return Some(format!(" · {s}")); } + if provider_uses_official_subscription(provider) { + if let Some(Some(s)) = app_state + .usage_cache + .with_subscription(app_type, format_subscription_summary) + { + return Some(format!(" · {s}")); + } + } } else { app_state .usage_cache .invalidate_script(app_type, provider_id); } - if provider.category.as_deref() == Some("official") { - if let Some(Some(s)) = app_state - .usage_cache - .with_subscription(app_type, format_subscription_summary) - { - return Some(format!(" · {s}")); - } + if !provider_uses_official_subscription(provider) { + app_state.usage_cache.invalidate_subscription(app_type); } None } @@ -768,8 +781,8 @@ pub fn schedule_tray_refresh(app: &tauri::AppHandle) { /// 雪崩请求;互斥锁被毒化时以上次状态为准继续推进,不会永久阻塞。 /// /// 刷新面与 `format_usage_suffix` 的展示面严格对齐 —— 每次悬停最多发 -/// `TRAY_SECTIONS.len()` 次外部请求,script 优先(覆盖 coding_plan / balance / -/// Copilot / 自定义脚本),否则当前 provider 必须是 `official` 才查订阅。 +/// `TRAY_SECTIONS.len()` 次外部请求;只有显式启用的用量查询(含官方订阅、 +/// coding_plan / balance / Copilot / 自定义脚本)才会发请求。 pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) { use crate::commands::CopilotAuthState; use futures::future::join_all; @@ -797,7 +810,6 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) { .visible_apps .unwrap_or_default(); - let mut subscription_futures = Vec::new(); let mut script_futures = Vec::new(); for section in TRAY_SECTIONS.iter() { @@ -831,9 +843,11 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) { } }; - // 与 format_usage_suffix 同一优先级:脚本启用 → 查脚本; - // 否则当前 provider 是 official → 查订阅;其它情况不发请求。 - if current.has_usage_script_enabled() { + // 与 format_usage_suffix 同一优先级:只有显式启用的用量查询才发请求。 + let is_official_provider = current.category.as_deref() == Some("official"); + if current.has_usage_script_enabled() + && (!is_official_provider || provider_uses_official_subscription(¤t)) + { let app_clone = app.clone(); let state = app.state::(); let copilot_state = app.state::(); @@ -852,22 +866,10 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) { log::debug!("[Tray] 刷新{log_name}供应商 {provider_id} 用量失败: {e}"); } }); - } else if current.category.as_deref() == Some("official") { - let app_clone = app.clone(); - let state = app.state::(); - let tool = app_type_str.to_string(); - subscription_futures.push(async move { - if let Err(e) = - crate::commands::get_subscription_quota(app_clone, state, tool).await - { - log::debug!("[Tray] 刷新{log_name}订阅用量失败(可能未登录): {e}"); - } - }); } } - // 两组并行启动,整体等待 —— 订阅/脚本互不依赖,没必要串行。 - futures::future::join(join_all(subscription_futures), join_all(script_futures)).await; + join_all(script_futures).await; } #[cfg(test)] @@ -875,7 +877,9 @@ mod tests { use super::{format_script_summary, format_subscription_summary, TRAY_ID}; use crate::provider::{UsageData, UsageResult}; use crate::services::subscription::{ - CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT, + CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH, + TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS, + TIER_SEVEN_DAY_SONNET, TIER_WEEKLY_LIMIT, }; #[test] @@ -988,6 +992,22 @@ mod tests { assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}"); } + #[test] + fn subscription_summary_week_aliases_use_highest_utilization() { + let quota = make_quota( + "claude", + true, + vec![ + tier(TIER_FIVE_HOUR, 10.0), + tier(TIER_SEVEN_DAY_OPUS, 20.0), + tier(TIER_SEVEN_DAY_SONNET, 95.0), + ], + ); + let s = format_subscription_summary("a).unwrap(); + assert!(s.contains("w95%"), "expected w95% in {s}"); + assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}"); + } + #[test] fn failure_quota_returns_none() { let quota = make_quota("claude", false, vec![tier("five_hour", 50.0)]); @@ -1074,6 +1094,59 @@ mod tests { assert!(s.contains("w50%"), "expected w50% in {s}"); } + #[test] + fn script_summary_official_subscription_claude_uses_h_and_w_labels() { + let r = usage_result( + true, + vec![ + usage_data(Some(TIER_FIVE_HOUR), 12.0), + usage_data(Some(TIER_SEVEN_DAY), 80.0), + ], + ); + let s = format_script_summary(&r).expect("should format"); + assert!(s.contains("h12%"), "expected h12% in {s}"); + assert!(s.contains("w80%"), "expected w80% in {s}"); + assert!( + !s.contains(TIER_SEVEN_DAY), + "tier machine name should not leak into label: {s}" + ); + } + + #[test] + fn script_summary_week_aliases_use_highest_utilization() { + let r = usage_result( + true, + vec![ + usage_data(Some(TIER_FIVE_HOUR), 10.0), + usage_data(Some(TIER_SEVEN_DAY_OPUS), 20.0), + usage_data(Some(TIER_SEVEN_DAY_SONNET), 95.0), + ], + ); + let s = format_script_summary(&r).unwrap(); + assert!(s.contains("w95%"), "expected w95% in {s}"); + assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}"); + } + + #[test] + fn script_summary_official_subscription_gemini_uses_short_labels() { + let r = usage_result( + true, + vec![ + usage_data(Some(TIER_GEMINI_PRO), 15.0), + usage_data(Some(TIER_GEMINI_FLASH), 42.0), + usage_data(Some(TIER_GEMINI_FLASH_LITE), 80.0), + ], + ); + let s = format_script_summary(&r).expect("should format"); + assert!(s.contains("p15%"), "expected p15% in {s}"); + assert!(s.contains("f42%"), "expected f42% in {s}"); + assert!(s.contains("l80%"), "expected l80% in {s}"); + assert!( + !s.contains("gemini_"), + "Gemini tier machine names should not leak into label: {s}" + ); + } + #[test] fn script_summary_single_bucket_fallback_with_plan_name() { let r = usage_result(true, vec![usage_data(Some("Copilot Pro"), 40.0)]); diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 77bc1f2c0..65471ca8a 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -9,6 +9,7 @@ interface SubscriptionQuotaFooterProps { appId: AppId; inline?: boolean; isCurrent?: boolean; + autoQueryInterval?: number; } interface SubscriptionQuotaViewProps { @@ -397,12 +398,18 @@ const SubscriptionQuotaFooter: React.FC = ({ appId, inline = false, isCurrent = false, + autoQueryInterval = 5, }) => { const { data: quota, isFetching: loading, refetch, - } = useSubscriptionQuota(appId, isCurrent, isCurrent); + } = useSubscriptionQuota( + appId, + isCurrent, + isCurrent && autoQueryInterval > 0, + autoQueryInterval, + ); if (!isCurrent) return null; diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 84a21e194..c5be05590 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -110,6 +110,9 @@ const generatePresetTemplates = ( // 官方余额查询模板不需要脚本,使用专用 Rust 查询 [TEMPLATE_TYPES.BALANCE]: "", + + // 官方订阅额度查询不需要脚本,使用 CLI/OAuth 凭据调用官方 API + [TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION]: "", }); // 模板名称国际化键映射 @@ -120,6 +123,8 @@ const TEMPLATE_NAME_KEYS: Record = { [TEMPLATE_TYPES.GITHUB_COPILOT]: "usageScript.templateCopilot", [TEMPLATE_TYPES.TOKEN_PLAN]: "usageScript.templateTokenPlan", [TEMPLATE_TYPES.BALANCE]: "usageScript.templateBalance", + [TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION]: + "usageScript.templateOfficialSubscription", }; /** 官方余额查询供应商检测 */ @@ -141,6 +146,45 @@ function detectBalanceProvider(baseUrl: string | undefined): boolean { return BALANCE_PROVIDERS.some((bp) => bp.pattern.test(baseUrl)); } +function isOfficialSubscriptionProvider(provider: Provider, appId: AppId) { + if (!["claude", "codex", "gemini"].includes(appId)) return false; + if (provider.category === "official") return true; + + const config = provider.settingsConfig as Record; + if (appId === "claude") { + const baseUrl = config?.env?.ANTHROPIC_BASE_URL; + return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""); + } + if (appId === "codex") { + const apiKey = config?.auth?.OPENAI_API_KEY; + const bearerToken = + typeof config?.config === "string" + ? extractCodexExperimentalBearerToken(config.config) + : undefined; + return ( + !bearerToken && + (!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) + ); + } + if (appId === "gemini") { + const env = config?.env || {}; + const apiKey = env.GEMINI_API_KEY; + const baseUrl = env.GOOGLE_GEMINI_BASE_URL; + return ( + (!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) && + (!baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === "")) + ); + } + return false; +} + +const NATIVE_USAGE_TEMPLATES = new Set([ + TEMPLATE_TYPES.GITHUB_COPILOT, + TEMPLATE_TYPES.TOKEN_PLAN, + TEMPLATE_TYPES.BALANCE, + TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION, +]); + const UsageScriptModal: React.FC = ({ provider, appId, @@ -238,22 +282,33 @@ const UsageScriptModal: React.FC = ({ }; const providerCredentials = getProviderCredentials(); + const isOfficialSubscription = isOfficialSubscriptionProvider( + provider, + appId, + ); const [script, setScript] = useState(() => { const savedScript = provider.meta?.usage_script; if (savedScript) { + const normalizedScript = createUsageScript(savedScript); + if ( + isOfficialSubscription && + normalizedScript.templateType !== TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION + ) { + return createUsageScript(); + } // 已有配置:如果是 coding_plan 但没有 codingPlanProvider,自动检测填充 if ( - savedScript.templateType === TEMPLATE_TYPES.TOKEN_PLAN && - !savedScript.codingPlanProvider + normalizedScript.templateType === TEMPLATE_TYPES.TOKEN_PLAN && + !normalizedScript.codingPlanProvider ) { return { - ...savedScript, + ...normalizedScript, codingPlanProvider: detectCodingPlanProvider(providerCredentials.baseUrl) || "kimi", }; } - return savedScript; + return normalizedScript; } const autoDetected = detectCodingPlanProvider(providerCredentials.baseUrl); @@ -265,6 +320,10 @@ const UsageScriptModal: React.FC = ({ return createUsageScript(); } + if (isOfficialSubscription) { + return createUsageScript(); + } + return createUsageScript({ code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL], }); @@ -327,9 +386,17 @@ const UsageScriptModal: React.FC = ({ return TEMPLATE_TYPES.GITHUB_COPILOT; } // 优先使用保存的 templateType - if (existingScript?.templateType) { + if ( + existingScript?.templateType && + (!isOfficialSubscription || + existingScript.templateType === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION) + ) { return existingScript.templateType as string; } + // 官方 CLI/OAuth 供应商默认使用官方订阅额度模板,但开关默认关闭 + if (isOfficialSubscription) { + return TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION; + } // 向后兼容:根据字段推断模板类型 // 检测 NEW_API 模板(有 accessToken 或 userId) if (existingScript?.accessToken || existingScript?.userId) { @@ -378,12 +445,8 @@ const UsageScriptModal: React.FC = ({ }; const handleSave = () => { - // Copilot、Coding Plan、Balance 模板不需要脚本验证 - if ( - selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && - selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && - selectedTemplate !== TEMPLATE_TYPES.BALANCE - ) { + // 专用模板不需要脚本验证 + if (!NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "")) { if (script.enabled && !script.code.trim()) { toast.error(t("usageScript.scriptEmpty")); return; @@ -403,6 +466,7 @@ const UsageScriptModal: React.FC = ({ | "github_copilot" | "token_plan" | "balance" + | "official_subscription" | undefined, }; onSave(scriptWithTemplate); @@ -412,6 +476,28 @@ const UsageScriptModal: React.FC = ({ const handleTest = async () => { setTesting(true); try { + // 官方订阅额度模板使用 CLI/OAuth 凭据和官方 API + if (selectedTemplate === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION) { + const { subscriptionApi } = await import("@/lib/api/subscription"); + const quota = await subscriptionApi.getQuota(appId); + if (quota.success && quota.tiers.length > 0) { + const summary = quota.tiers + .map((tier) => `${tier.name}: ${Math.round(tier.utilization)}%`) + .join(", "); + toast.success(`${t("usageScript.testSuccess")}${summary}`, { + duration: 3000, + closeButton: true, + }); + queryClient.setQueryData(["subscription", "quota", appId], quota); + } else { + toast.error( + `${t("usageScript.testFailed")}: ${quota.error || t("endpointTest.noResult")}`, + { duration: 5000 }, + ); + } + return; + } + // 官方余额查询模板使用专用 API if (selectedTemplate === TEMPLATE_TYPES.BALANCE) { const baseUrl = providerCredentials.baseUrl ?? ""; @@ -654,6 +740,16 @@ const UsageScriptModal: React.FC = ({ accessToken: undefined, userId: undefined, }); + } else if (presetName === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION) { + // 官方订阅额度查询不需要脚本,使用 CLI/OAuth 凭据 + setScript({ + ...script, + code: "", + apiKey: undefined, + baseUrl: undefined, + accessToken: undefined, + userId: undefined, + }); } setSelectedTemplate(presetName); } @@ -681,7 +777,10 @@ const UsageScriptModal: React.FC = ({ variant="outline" size="sm" onClick={handleFormat} - disabled={!script.enabled} + disabled={ + !script.enabled || + NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "") + } title={t("usageScript.format")} > @@ -742,8 +841,15 @@ const UsageScriptModal: React.FC = ({ if (isCopilotProvider) { return name === TEMPLATE_TYPES.GITHUB_COPILOT; } + // 官方 CLI/OAuth 供应商只显示官方订阅额度模板 + if (isOfficialSubscription) { + return name === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION; + } // 非 Copilot 供应商不显示 copilot 模板 - return name !== TEMPLATE_TYPES.GITHUB_COPILOT; + return ( + name !== TEMPLATE_TYPES.GITHUB_COPILOT && + name !== TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION + ); }) .map((name) => { const isSelected = selectedTemplate === name; @@ -865,6 +971,15 @@ const UsageScriptModal: React.FC = ({ )} + {/* 官方订阅额度模式:自动提示 */} + {selectedTemplate === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION && ( +
+

+ {t("usageScript.officialSubscriptionHint")} +

+
+ )} + {/* Coding Plan 模式:供应商选择 */} {selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && (
@@ -1191,43 +1306,41 @@ const UsageScriptModal: React.FC = ({
- {/* 提取器代码 - Copilot 模板不需要 */} - {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && - selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && ( -
-
- -
- {t("usageScript.extractorHint")} -
+ {/* 提取器代码 - 专用模板不需要 */} + {!NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "") && ( +
+
+ +
+ {t("usageScript.extractorHint")}
- - setScript((prev) => ({ ...prev, code: value })) - } - height={480} - language="javascript" - showMinimap={false} - />
- )} + + setScript((prev) => ({ ...prev, code: value })) + } + height={480} + language="javascript" + showMinimap={false} + /> +
+ )} - {/* 帮助信息 - Copilot 模板不需要 */} - {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && - selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && ( -
-

- {t("usageScript.scriptHelp")} -

-
-
- {t("usageScript.configFormat")} -
-                      {`({
+          {/* 帮助信息 - 专用模板不需要 */}
+          {!NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "") && (
+            
+

+ {t("usageScript.scriptHelp")} +

+
+
+ {t("usageScript.configFormat")} +
+                    {`({
   request: {
     url: "{{baseUrl}}/api/usage",
     method: "POST",
@@ -1244,39 +1357,39 @@ const UsageScriptModal: React.FC = ({
     };
   }
 })`}
-                    
-
+
+
-
- {t("usageScript.extractorFormat")} -
    -
  • {t("usageScript.fieldIsValid")}
  • -
  • {t("usageScript.fieldInvalidMessage")}
  • -
  • {t("usageScript.fieldRemaining")}
  • -
  • {t("usageScript.fieldUnit")}
  • -
  • {t("usageScript.fieldPlanName")}
  • -
  • {t("usageScript.fieldTotal")}
  • -
  • {t("usageScript.fieldUsed")}
  • -
  • {t("usageScript.fieldExtra")}
  • -
-
+
+ {t("usageScript.extractorFormat")} +
    +
  • {t("usageScript.fieldIsValid")}
  • +
  • {t("usageScript.fieldInvalidMessage")}
  • +
  • {t("usageScript.fieldRemaining")}
  • +
  • {t("usageScript.fieldUnit")}
  • +
  • {t("usageScript.fieldPlanName")}
  • +
  • {t("usageScript.fieldTotal")}
  • +
  • {t("usageScript.fieldUsed")}
  • +
  • {t("usageScript.fieldExtra")}
  • +
+
-
- {t("usageScript.tips")} -
    -
  • - {t("usageScript.tip1", { - apiKey: "{{apiKey}}", - baseUrl: "{{baseUrl}}", - })} -
  • -
  • {t("usageScript.tip2")}
  • -
  • {t("usageScript.tip3")}
  • -
-
+
+ {t("usageScript.tips")} +
    +
  • + {t("usageScript.tip1", { + apiKey: "{{apiKey}}", + baseUrl: "{{baseUrl}}", + })} +
  • +
  • {t("usageScript.tip2")}
  • +
  • {t("usageScript.tip3")}
  • +
- )} +
+ )}
)} diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index a61cead88..017a34a5c 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -14,7 +14,7 @@ import UsageFooter from "@/components/UsageFooter"; import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter"; import CopilotQuotaFooter from "@/components/CopilotQuotaFooter"; import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter"; -import { PROVIDER_TYPES } from "@/config/constants"; +import { PROVIDER_TYPES, TEMPLATE_TYPES } from "@/config/constants"; import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; @@ -192,6 +192,13 @@ export function ProviderCard({ const usageEnabled = provider.meta?.usage_script?.enabled ?? false; const isOfficial = isOfficialProvider(provider, appId); + const supportsOfficialSubscription = + isOfficial && ["claude", "codex", "gemini"].includes(appId); + const isOfficialSubscriptionUsage = + provider.meta?.usage_script?.templateType === + TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION; + const officialSubscriptionEnabled = + supportsOfficialSubscription && usageEnabled && isOfficialSubscriptionUsage; const isOfficialBlockedByProxy = isProxyTakeover && (provider.category === "official" || isOfficial); const isCopilot = @@ -231,7 +238,7 @@ export function ProviderCard({ : 0; const { data: usage } = useUsageQuery(provider.id, appId, { - enabled: usageEnabled, + enabled: usageEnabled && !isOfficial && !isOfficialSubscriptionUsage, autoQueryInterval, }); @@ -469,11 +476,16 @@ export function ProviderCard({ isCurrent={isCurrent} /> ) : isOfficial ? ( - + officialSubscriptionEnabled ? ( + + ) : null ) : hasMultiplePlans ? (
@@ -540,7 +552,9 @@ export function ProviderCard({ : undefined } onConfigureUsage={ - isOfficial || isCopilot || isCodexOauth + (isOfficial && !supportsOfficialSubscription) || + isCopilot || + isCodexOauth ? undefined : () => onConfigureUsage(provider) } diff --git a/src/config/constants.ts b/src/config/constants.ts index d6a3aab8d..c4ae2143f 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -12,6 +12,7 @@ export const TEMPLATE_TYPES = { GITHUB_COPILOT: "github_copilot", TOKEN_PLAN: "token_plan", BALANCE: "balance", + OFFICIAL_SUBSCRIPTION: "official_subscription", } as const; export type TemplateType = (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES]; diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 583e9b794..139154e66 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -311,6 +311,9 @@ export function useProviderActions( await queryClient.invalidateQueries({ queryKey: ["usage", provider.id, activeApp], }); + await queryClient.invalidateQueries({ + queryKey: ["subscription", "quota", activeApp], + }); toast.success( t("provider.usageSaved", { defaultValue: "用量查询配置已保存", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index af0a09d4e..4c6e394bc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1488,9 +1488,11 @@ "templateCopilot": "GitHub Copilot", "templateTokenPlan": "Token Plan", "templateBalance": "Official", + "templateOfficialSubscription": "Official Subscription", "copilotAutoAuth": "Auto OAuth authentication, no manual credentials needed", "tokenPlanHint": "Automatically uses the provider's API Key and Base URL to query Token Plan quota", "balanceHint": "Automatically uses the provider's API Key to query account balance", + "officialSubscriptionHint": "Reads the local CLI OAuth credentials and calls the official API to query subscription quota. Disabled by default and only requests after you enable it.", "resetDate": "Reset date", "premiumRequests": "Premium Requests", "credentialsConfig": "Credentials", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 45d2556ae..e2cddeec2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1488,9 +1488,11 @@ "templateCopilot": "GitHub Copilot", "templateTokenPlan": "Token Plan", "templateBalance": "公式", + "templateOfficialSubscription": "公式サブスクリプション", "copilotAutoAuth": "OAuth 認証を自動使用、手動設定不要", "tokenPlanHint": "プロバイダーのAPI KeyとBase URLを使用してToken Planクォータを自動クエリ", "balanceHint": "プロバイダーのAPI Keyを使用してアカウント残高を自動クエリ", + "officialSubscriptionHint": "ローカル CLI の OAuth 認証情報を読み取り、公式 API でサブスクリプション枠を照会します。既定では無効で、有効化した後のみリクエストします。", "resetDate": "リセット日", "premiumRequests": "Premium リクエスト", "credentialsConfig": "認証情報", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 5e0e908cc..9c69ee38d 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1434,9 +1434,11 @@ "templateCopilot": "GitHub Copilot", "templateTokenPlan": "Token Plan", "templateBalance": "官方", + "templateOfficialSubscription": "官方訂閱", "copilotAutoAuth": "自動使用 OAuth 驗證,無需手動設定憑證", "tokenPlanHint": "自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度", "balanceHint": "自動使用供應商的 API Key 查詢帳號餘額", + "officialSubscriptionHint": "讀取本機 CLI 的 OAuth 憑證,並呼叫官方介面查詢訂閱額度。預設關閉,只有啟用後才會請求。", "resetDate": "重設日期", "premiumRequests": "Premium 請求", "credentialsConfig": "憑證設定", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 3eaabcaa1..d65bdf5ff 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1488,9 +1488,11 @@ "templateCopilot": "GitHub Copilot", "templateTokenPlan": "Token Plan", "templateBalance": "官方", + "templateOfficialSubscription": "官方订阅", "copilotAutoAuth": "自动使用 OAuth 认证,无需手动配置凭证", "tokenPlanHint": "自动使用供应商的 API Key 和 Base URL 查询 Token Plan 额度", "balanceHint": "自动使用供应商的 API Key 查询账户余额", + "officialSubscriptionHint": "读取本机 CLI 的 OAuth 凭据,并调用官方接口查询订阅额度。默认关闭,只有启用后才会请求。", "resetDate": "重置日期", "premiumRequests": "Premium 请求", "credentialsConfig": "凭证配置", diff --git a/src/lib/query/subscription.ts b/src/lib/query/subscription.ts index 4262b55aa..7da5a7485 100644 --- a/src/lib/query/subscription.ts +++ b/src/lib/query/subscription.ts @@ -16,15 +16,24 @@ export function useSubscriptionQuota( appId: AppId, enabled: boolean, autoQuery = false, + autoQueryIntervalMinutes = 5, ) { + const refetchInterval = + autoQuery && autoQueryIntervalMinutes > 0 + ? Math.max(autoQueryIntervalMinutes, 1) * 60 * 1000 + : false; + return useQuery({ queryKey: subscriptionKeys.quota(appId), queryFn: () => subscriptionApi.getQuota(appId), enabled: enabled && ["claude", "codex", "gemini"].includes(appId), - refetchInterval: autoQuery ? REFETCH_INTERVAL : false, - refetchIntervalInBackground: autoQuery, - refetchOnWindowFocus: autoQuery, - staleTime: REFETCH_INTERVAL, + refetchInterval, + refetchIntervalInBackground: Boolean(refetchInterval), + refetchOnWindowFocus: Boolean(refetchInterval), + staleTime: + autoQueryIntervalMinutes > 0 + ? Math.max(autoQueryIntervalMinutes, 1) * 60 * 1000 + : REFETCH_INTERVAL, retry: 1, }); }