feat(usage): add official subscription quota template with unified tier rendering

Changes:
- Add official_subscription template type for Claude/Codex/Gemini
- Replace implicit 'category=official auto-query' with explicit opt-in template
- Default disabled; users enable via usage script modal with configurable interval
- Unify tier→label mapping across subscription and script paths via labeled_tier_parts()
- Fix tray rendering: week aliases (seven_day/opus/sonnet) now use highest utilization
- Add depth guard: official_subscription checks enabled flag in query_provider_usage_inner
- Add cache invalidation symmetry: invalidate_subscription() for disabled providers
- i18n: add templateOfficialSubscription + hint in zh/en/ja/zh-TW

Backend (Rust):
- provider.rs: add TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION branch, flatten SubscriptionQuota→UsageData
- tray.rs: extract labeled_tier_parts() shared by both summary functions, use max_by for multi-alias groups
- usage_cache.rs: add invalidate_subscription() method
- Test coverage: add week-alias highest-utilization tests for both paths

Frontend (TypeScript):
- UsageScriptModal: add official_subscription to templates, auto-detect for official providers
- ProviderCard: gate useUsageQuery with !isOfficialSubscriptionUsage, pass autoQueryInterval to footer
- SubscriptionQuotaFooter: accept autoQueryInterval prop, default 0 (disabled)
- constants.ts: add TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION

Fixes tier rendering regression where:
- Claude/Codex: seven_day was missed (only weekly_limit matched) → lost 7-day window in tray
- Gemini: gemini_pro/flash/flash_lite fell through to fallback → leaked machine names
- Multi-window (opus+sonnet): find() took first, not worst → underestimated utilization and emoji color

All tests pass (cargo test + cargo clippy clean).
This commit is contained in:
Jason
2026-06-05 19:02:23 +08:00
parent 03a9296c1f
commit 473f21971d
13 changed files with 459 additions and 173 deletions
+45
View File
@@ -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<crate::provider::UsageData> = 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