mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+157
-84
@@ -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<String> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
}
|
||||
|
||||
fn format_script_summary(result: &crate::provider::UsageResult) -> Option<String> {
|
||||
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<String
|
||||
return None;
|
||||
}
|
||||
|
||||
// commands::provider 的 token_plan 分支把 SubscriptionQuota 的每个 tier
|
||||
// 扁平化为一条 UsageData(plan_name 承载 tier 名),所以这里按 plan_name
|
||||
// 识别双桶形态,其余 usage 结果(Copilot / balance / 自定义脚本)走 fallback。
|
||||
const TOKEN_PLAN_LABELS: &[(&str, &str)] = &[(TIER_FIVE_HOUR, "h"), (TIER_WEEKLY_LIMIT, "w")];
|
||||
|
||||
let mut parts: Vec<(&'static str, f64)> = 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<String
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_uses_official_subscription(provider: &crate::provider::Provider) -> 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<String> {
|
||||
// 当前脚本是否启用:禁用/删除时不再沿用旧 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::<AppState>();
|
||||
let copilot_state = app.state::<CopilotAuthState>();
|
||||
@@ -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::<AppState>();
|
||||
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)]);
|
||||
|
||||
@@ -9,6 +9,7 @@ interface SubscriptionQuotaFooterProps {
|
||||
appId: AppId;
|
||||
inline?: boolean;
|
||||
isCurrent?: boolean;
|
||||
autoQueryInterval?: number;
|
||||
}
|
||||
|
||||
interface SubscriptionQuotaViewProps {
|
||||
@@ -397,12 +398,18 @@ const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
[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<string, any>;
|
||||
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<string>([
|
||||
TEMPLATE_TYPES.GITHUB_COPILOT,
|
||||
TEMPLATE_TYPES.TOKEN_PLAN,
|
||||
TEMPLATE_TYPES.BALANCE,
|
||||
TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION,
|
||||
]);
|
||||
|
||||
const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
provider,
|
||||
appId,
|
||||
@@ -238,22 +282,33 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
};
|
||||
|
||||
const providerCredentials = getProviderCredentials();
|
||||
const isOfficialSubscription = isOfficialSubscriptionProvider(
|
||||
provider,
|
||||
appId,
|
||||
);
|
||||
|
||||
const [script, setScript] = useState<UsageScript>(() => {
|
||||
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<UsageScriptModalProps> = ({
|
||||
return createUsageScript();
|
||||
}
|
||||
|
||||
if (isOfficialSubscription) {
|
||||
return createUsageScript();
|
||||
}
|
||||
|
||||
return createUsageScript({
|
||||
code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL],
|
||||
});
|
||||
@@ -327,9 +386,17 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
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<UsageScriptModalProps> = ({
|
||||
};
|
||||
|
||||
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<UsageScriptModalProps> = ({
|
||||
| "github_copilot"
|
||||
| "token_plan"
|
||||
| "balance"
|
||||
| "official_subscription"
|
||||
| undefined,
|
||||
};
|
||||
onSave(scriptWithTemplate);
|
||||
@@ -412,6 +476,28 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
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<UsageScriptModalProps> = ({
|
||||
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<UsageScriptModalProps> = ({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFormat}
|
||||
disabled={!script.enabled}
|
||||
disabled={
|
||||
!script.enabled ||
|
||||
NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "")
|
||||
}
|
||||
title={t("usageScript.format")}
|
||||
>
|
||||
<Wand2 size={14} className="mr-1" />
|
||||
@@ -742,8 +841,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
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<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 官方订阅额度模式:自动提示 */}
|
||||
{selectedTemplate === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION && (
|
||||
<div className="space-y-2 border-t border-white/10 pt-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("usageScript.officialSubscriptionHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coding Plan 模式:供应商选择 */}
|
||||
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && (
|
||||
<div className="space-y-3 border-t border-white/10 pt-3">
|
||||
@@ -1191,43 +1306,41 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提取器代码 - Copilot 模板不需要 */}
|
||||
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
|
||||
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && (
|
||||
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-medium">
|
||||
{t("usageScript.extractorCode")}
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("usageScript.extractorHint")}
|
||||
</div>
|
||||
{/* 提取器代码 - 专用模板不需要 */}
|
||||
{!NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "") && (
|
||||
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-medium">
|
||||
{t("usageScript.extractorCode")}
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("usageScript.extractorHint")}
|
||||
</div>
|
||||
<JsonEditor
|
||||
id="usage-code"
|
||||
value={script.code || ""}
|
||||
onChange={(value) =>
|
||||
setScript((prev) => ({ ...prev, code: value }))
|
||||
}
|
||||
height={480}
|
||||
language="javascript"
|
||||
showMinimap={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<JsonEditor
|
||||
id="usage-code"
|
||||
value={script.code || ""}
|
||||
onChange={(value) =>
|
||||
setScript((prev) => ({ ...prev, code: value }))
|
||||
}
|
||||
height={480}
|
||||
language="javascript"
|
||||
showMinimap={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 帮助信息 - Copilot 模板不需要 */}
|
||||
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
|
||||
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && (
|
||||
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
|
||||
<h4 className="font-medium mb-2">
|
||||
{t("usageScript.scriptHelp")}
|
||||
</h4>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<strong>{t("usageScript.configFormat")}</strong>
|
||||
<pre className="mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto">
|
||||
{`({
|
||||
{/* 帮助信息 - 专用模板不需要 */}
|
||||
{!NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "") && (
|
||||
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
|
||||
<h4 className="font-medium mb-2">
|
||||
{t("usageScript.scriptHelp")}
|
||||
</h4>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<strong>{t("usageScript.configFormat")}</strong>
|
||||
<pre className="mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto">
|
||||
{`({
|
||||
request: {
|
||||
url: "{{baseUrl}}/api/usage",
|
||||
method: "POST",
|
||||
@@ -1244,39 +1357,39 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
};
|
||||
}
|
||||
})`}
|
||||
</pre>
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>{t("usageScript.extractorFormat")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>{t("usageScript.fieldIsValid")}</li>
|
||||
<li>{t("usageScript.fieldInvalidMessage")}</li>
|
||||
<li>{t("usageScript.fieldRemaining")}</li>
|
||||
<li>{t("usageScript.fieldUnit")}</li>
|
||||
<li>{t("usageScript.fieldPlanName")}</li>
|
||||
<li>{t("usageScript.fieldTotal")}</li>
|
||||
<li>{t("usageScript.fieldUsed")}</li>
|
||||
<li>{t("usageScript.fieldExtra")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("usageScript.extractorFormat")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>{t("usageScript.fieldIsValid")}</li>
|
||||
<li>{t("usageScript.fieldInvalidMessage")}</li>
|
||||
<li>{t("usageScript.fieldRemaining")}</li>
|
||||
<li>{t("usageScript.fieldUnit")}</li>
|
||||
<li>{t("usageScript.fieldPlanName")}</li>
|
||||
<li>{t("usageScript.fieldTotal")}</li>
|
||||
<li>{t("usageScript.fieldUsed")}</li>
|
||||
<li>{t("usageScript.fieldExtra")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<strong>{t("usageScript.tips")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>
|
||||
{t("usageScript.tip1", {
|
||||
apiKey: "{{apiKey}}",
|
||||
baseUrl: "{{baseUrl}}",
|
||||
})}
|
||||
</li>
|
||||
<li>{t("usageScript.tip2")}</li>
|
||||
<li>{t("usageScript.tip3")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
<strong>{t("usageScript.tips")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>
|
||||
{t("usageScript.tip1", {
|
||||
apiKey: "{{apiKey}}",
|
||||
baseUrl: "{{baseUrl}}",
|
||||
})}
|
||||
</li>
|
||||
<li>{t("usageScript.tip2")}</li>
|
||||
<li>{t("usageScript.tip3")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<SubscriptionQuotaFooter
|
||||
appId={appId}
|
||||
inline={true}
|
||||
isCurrent={isCurrent}
|
||||
/>
|
||||
officialSubscriptionEnabled ? (
|
||||
<SubscriptionQuotaFooter
|
||||
appId={appId}
|
||||
inline={true}
|
||||
isCurrent={isCurrent}
|
||||
autoQueryInterval={
|
||||
provider.meta?.usage_script?.autoQueryInterval ?? 0
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
) : hasMultiplePlans ? (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium">
|
||||
@@ -540,7 +552,9 @@ export function ProviderCard({
|
||||
: undefined
|
||||
}
|
||||
onConfigureUsage={
|
||||
isOfficial || isCopilot || isCodexOauth
|
||||
(isOfficial && !supportsOfficialSubscription) ||
|
||||
isCopilot ||
|
||||
isCodexOauth
|
||||
? undefined
|
||||
: () => onConfigureUsage(provider)
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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: "用量查询配置已保存",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "認証情報",
|
||||
|
||||
@@ -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": "憑證設定",
|
||||
|
||||
@@ -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": "凭证配置",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user