diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a751fb54a..fed30fe30 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ mod session_manager; mod settings; pub mod skill; mod stream_check; +mod subscription; mod sync_support; mod lightweight; @@ -49,6 +50,7 @@ pub use session_manager::*; pub use settings::*; pub use skill::*; pub use stream_check::*; +pub use subscription::*; pub use lightweight::*; pub use usage::*; diff --git a/src-tauri/src/commands/subscription.rs b/src-tauri/src/commands/subscription.rs new file mode 100644 index 000000000..00b456eb7 --- /dev/null +++ b/src-tauri/src/commands/subscription.rs @@ -0,0 +1,10 @@ +use crate::services::subscription::SubscriptionQuota; + +/// 查询官方订阅额度 +/// +/// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。 +/// 不需要 AppState(不访问数据库),直接读文件 + 发 HTTP。 +#[tauri::command] +pub async fn get_subscription_quota(tool: String) -> Result { + crate::services::subscription::get_subscription_quota(&tool).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cf06fa896..43bc47757 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -888,6 +888,8 @@ pub fn run() { // usage query commands::queryProviderUsage, commands::testUsageScript, + // subscription quota + commands::get_subscription_quota, // New MCP via config.json (SSOT) commands::get_mcp_config, commands::upsert_mcp_server_in_config, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index e84de582e..a7c75e162 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -10,6 +10,7 @@ pub mod proxy; pub mod skill; pub mod speedtest; pub mod stream_check; +pub mod subscription; pub mod usage_stats; pub mod webdav; pub mod webdav_auto_sync; diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs new file mode 100644 index 000000000..47f0957dd --- /dev/null +++ b/src-tauri/src/services/subscription.rs @@ -0,0 +1,460 @@ +//! 官方订阅额度查询服务 +//! +//! 读取 CLI 工具的已有 OAuth 凭据,查询官方订阅额度。 +//! 第一层:仅读取凭据,不实现登录/刷新。 + +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::config; + +// ── 数据类型 ────────────────────────────────────────────── + +/// 凭据状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CredentialStatus { + Valid, + Expired, + NotFound, + ParseError, +} + +/// 单个限速窗口(如 5小时会话、7天周期) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuotaTier { + /// 窗口标识:five_hour, seven_day, seven_day_opus, seven_day_sonnet 等 + pub name: String, + /// 使用百分比 0–100 + pub utilization: f64, + /// ISO 8601 重置时间 + pub resets_at: Option, +} + +/// 超额使用信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtraUsage { + pub is_enabled: bool, + pub monthly_limit: Option, + pub used_credits: Option, + pub utilization: Option, + pub currency: Option, +} + +/// 订阅额度查询结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionQuota { + pub tool: String, + pub credential_status: CredentialStatus, + pub credential_message: Option, + pub success: bool, + pub tiers: Vec, + pub extra_usage: Option, + pub error: Option, + pub queried_at: Option, +} + +impl SubscriptionQuota { + fn not_found(tool: &str) -> Self { + Self { + tool: tool.to_string(), + credential_status: CredentialStatus::NotFound, + credential_message: None, + success: false, + tiers: vec![], + extra_usage: None, + error: None, + queried_at: None, + } + } + + fn error(tool: &str, status: CredentialStatus, message: String) -> Self { + Self { + tool: tool.to_string(), + credential_status: status, + credential_message: Some(message.clone()), + success: false, + tiers: vec![], + extra_usage: None, + error: Some(message), + queried_at: Some(now_millis()), + } + } +} + +// ── Claude 凭据读取 ────────────────────────────────────── + +/// Claude OAuth 凭据文件中的嵌套结构 +#[derive(Deserialize)] +struct ClaudeOAuthEntry { + #[serde(rename = "accessToken")] + access_token: Option, + #[serde(rename = "expiresAt")] + expires_at: Option, +} + +/// 读取 Claude OAuth 凭据 +/// +/// 按优先级尝试以下来源: +/// 1. macOS Keychain (service: "Claude Code-credentials") +/// 2. 凭据文件 ~/.claude/.credentials.json +/// +/// JSON 格式(两种 key 都兼容): +/// {"claudeAiOauth": {"accessToken": "...", "expiresAt": ...}} +/// {"claude.ai_oauth": {"accessToken": "...", "expiresAt": ...}} +fn read_claude_credentials() -> (Option, CredentialStatus, Option) { + // 来源 1: macOS Keychain + #[cfg(target_os = "macos")] + { + if let Some(result) = read_claude_credentials_from_keychain() { + return result; + } + } + + // 来源 2: 凭据文件 + read_claude_credentials_from_file() +} + +/// 从 macOS Keychain 读取 Claude 凭据 +#[cfg(target_os = "macos")] +fn read_claude_credentials_from_keychain( +) -> Option<(Option, CredentialStatus, Option)> { + let output = std::process::Command::new("security") + .args([ + "find-generic-password", + "-s", + "Claude Code-credentials", + "-w", + ]) + .output() + .ok()?; + + if !output.status.success() { + return None; // Keychain 中无此条目,回退到文件 + } + + let json_str = String::from_utf8(output.stdout).ok()?; + let json_str = json_str.trim(); + if json_str.is_empty() { + return None; + } + + Some(parse_claude_credentials_json(json_str)) +} + +/// 从文件读取 Claude 凭据 +fn read_claude_credentials_from_file() -> (Option, CredentialStatus, Option) { + let cred_path = config::get_claude_config_dir().join(".credentials.json"); + + if !cred_path.exists() { + return (None, CredentialStatus::NotFound, None); + } + + let content = match std::fs::read_to_string(&cred_path) { + Ok(c) => c, + Err(e) => { + return ( + None, + CredentialStatus::ParseError, + Some(format!("Failed to read credentials file: {e}")), + ); + } + }; + + parse_claude_credentials_json(&content) +} + +/// 解析 Claude 凭据 JSON(Keychain 和文件共用) +fn parse_claude_credentials_json( + content: &str, +) -> (Option, CredentialStatus, Option) { + let parsed: serde_json::Value = match serde_json::from_str(content) { + Ok(v) => v, + Err(e) => { + return ( + None, + CredentialStatus::ParseError, + Some(format!("Failed to parse credentials JSON: {e}")), + ); + } + }; + + // 兼容两种 key 名 + let entry_value = parsed + .get("claudeAiOauth") + .or_else(|| parsed.get("claude.ai_oauth")); + + let entry_value = match entry_value { + Some(v) => v, + None => { + return ( + None, + CredentialStatus::ParseError, + Some("No OAuth entry found in credentials".to_string()), + ); + } + }; + + let entry: ClaudeOAuthEntry = match serde_json::from_value(entry_value.clone()) { + Ok(e) => e, + Err(e) => { + return ( + None, + CredentialStatus::ParseError, + Some(format!("Failed to parse OAuth entry: {e}")), + ); + } + }; + + let access_token = match entry.access_token { + Some(t) if !t.is_empty() => t, + _ => { + return ( + None, + CredentialStatus::ParseError, + Some("accessToken is empty or missing".to_string()), + ); + } + }; + + // 检查 token 是否过期 + if let Some(expires_at) = entry.expires_at { + if is_token_expired(&expires_at) { + return ( + Some(access_token), + CredentialStatus::Expired, + Some("OAuth token has expired".to_string()), + ); + } + } + + (Some(access_token), CredentialStatus::Valid, None) +} + +/// 判断 token 是否过期,兼容 Unix 时间戳(秒/毫秒)和 ISO 字符串 +fn is_token_expired(expires_at: &serde_json::Value) -> bool { + let now_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + match expires_at { + serde_json::Value::Number(n) => { + if let Some(ts) = n.as_u64() { + // 区分秒和毫秒(毫秒级时间戳大于 1e12) + let ts_secs = if ts > 1_000_000_000_000 { + ts / 1000 + } else { + ts + }; + ts_secs < now_secs + } else { + false + } + } + serde_json::Value::String(s) => { + // 尝试解析 ISO 8601 格式 + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) { + (dt.timestamp() as u64) < now_secs + } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") + { + (dt.and_utc().timestamp() as u64) < now_secs + } else { + false // 无法解析时不视为过期 + } + } + _ => false, + } +} + +// ── Claude API 查询 ────────────────────────────────────── + +/// Claude OAuth 用量 API 响应中的单个窗口 +#[derive(Deserialize)] +struct ApiUsageWindow { + utilization: Option, + resets_at: Option, +} + +/// Claude OAuth 用量 API 响应中的超额用量 +#[derive(Deserialize)] +struct ApiExtraUsage { + is_enabled: Option, + monthly_limit: Option, + used_credits: Option, + utilization: Option, + currency: Option, +} + +/// 已知的 Claude 用量窗口名称 +const KNOWN_TIERS: &[&str] = &[ + "five_hour", + "seven_day", + "seven_day_opus", + "seven_day_sonnet", +]; + +/// 查询 Claude 官方订阅额度 +async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { + let client = crate::proxy::http_client::get(); + + let resp = client + .get("https://api.anthropic.com/api/oauth/usage") + .header("Authorization", format!("Bearer {access_token}")) + .header("anthropic-beta", "oauth-2025-04-20") + .header("Accept", "application/json") + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => { + return SubscriptionQuota::error( + "claude", + CredentialStatus::Valid, + format!("Network error: {e}"), + ); + } + }; + + let status = resp.status(); + + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return SubscriptionQuota::error( + "claude", + CredentialStatus::Expired, + format!("Authentication failed (HTTP {status}). Please re-login with Claude CLI."), + ); + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return SubscriptionQuota::error( + "claude", + CredentialStatus::Valid, + format!("API error (HTTP {status}): {body}"), + ); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => { + return SubscriptionQuota::error( + "claude", + CredentialStatus::Valid, + format!("Failed to parse API response: {e}"), + ); + } + }; + + // 解析已知的 tier 窗口 + let mut tiers = Vec::new(); + for &tier_name in KNOWN_TIERS { + if let Some(window) = body.get(tier_name) { + if let Ok(w) = serde_json::from_value::(window.clone()) { + if let Some(util) = w.utilization { + tiers.push(QuotaTier { + name: tier_name.to_string(), + utilization: util, + resets_at: w.resets_at, + }); + } + } + } + } + + // 也解析未知窗口(API 可能返回新的窗口类型) + if let Some(obj) = body.as_object() { + for (key, value) in obj { + if key == "extra_usage" || KNOWN_TIERS.contains(&key.as_str()) { + continue; + } + if let Ok(w) = serde_json::from_value::(value.clone()) { + if let Some(util) = w.utilization { + tiers.push(QuotaTier { + name: key.clone(), + utilization: util, + resets_at: w.resets_at, + }); + } + } + } + } + + // 解析超额使用 + let extra_usage = body.get("extra_usage").and_then(|v| { + serde_json::from_value::(v.clone()) + .ok() + .map(|e| ExtraUsage { + is_enabled: e.is_enabled.unwrap_or(false), + monthly_limit: e.monthly_limit, + used_credits: e.used_credits, + utilization: e.utilization, + currency: e.currency, + }) + }); + + SubscriptionQuota { + tool: "claude".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success: true, + tiers, + extra_usage, + error: None, + queried_at: Some(now_millis()), + } +} + +// ── 入口函数 ────────────────────────────────────────────── + +/// 查询指定 CLI 工具的官方订阅额度 +pub async fn get_subscription_quota(tool: &str) -> Result { + match tool { + "claude" => { + let (token, status, message) = read_claude_credentials(); + + match status { + CredentialStatus::NotFound => Ok(SubscriptionQuota::not_found("claude")), + CredentialStatus::ParseError => Ok(SubscriptionQuota::error( + "claude", + CredentialStatus::ParseError, + message.unwrap_or_else(|| "Failed to parse credentials".to_string()), + )), + CredentialStatus::Expired => { + // 即使过期也尝试调用 API(token 可能实际上仍有效) + if let Some(token) = token { + let result = query_claude_quota(&token).await; + if result.success { + return Ok(result); + } + } + Ok(SubscriptionQuota::error( + "claude", + CredentialStatus::Expired, + message.unwrap_or_else(|| "OAuth token has expired".to_string()), + )) + } + CredentialStatus::Valid => { + let token = token.expect("token must be Some when status is Valid"); + Ok(query_claude_quota(&token).await) + } + } + } + // Codex / Gemini: 暂不支持 + _ => Ok(SubscriptionQuota::not_found(tool)), + } +} + +// ── 辅助函数 ────────────────────────────────────────────── + +fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx new file mode 100644 index 000000000..6666f74ba --- /dev/null +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -0,0 +1,359 @@ +import React from "react"; +import { RefreshCw, AlertCircle, Clock } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { AppId } from "@/lib/api"; +import { useSubscriptionQuota } from "@/lib/query/subscription"; +import type { QuotaTier } from "@/types/subscription"; + +interface SubscriptionQuotaFooterProps { + appId: AppId; + inline?: boolean; +} + +/** 已知 tier 名称的显示映射 */ +const TIER_I18N_KEYS: Record = { + five_hour: "subscription.fiveHour", + seven_day: "subscription.sevenDay", + seven_day_opus: "subscription.sevenDayOpus", + seven_day_sonnet: "subscription.sevenDaySonnet", +}; + +/** 根据使用百分比返回颜色 class */ +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 { + if (!resetsAt) return null; + const diffMs = new Date(resetsAt).getTime() - Date.now(); + if (diffMs <= 0) return null; + + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + + if (hours > 24) { + const days = Math.floor(hours / 24); + return `${days}d${hours % 24}h`; + } + if (hours > 0) return `${hours}h${minutes}m`; + return `${minutes}m`; +} + +/** 格式化重置时间为倒计时文本(带 i18n 模板) */ +function formatResetTime( + resetsAt: string | null, + t: (key: string, options?: Record) => string, +): string | null { + const time = countdownStr(resetsAt); + if (!time) return null; + return t("subscription.resetsIn", { time }); +} + +/** 不需要在 inline 模式显示的 tier */ +const HIDDEN_INLINE_TIERS = new Set(["seven_day_sonnet"]); + +/** 格式化相对时间(与 UsageFooter 一致) */ +function formatRelativeTime( + timestamp: number, + now: number, + t: (key: string, options?: { count?: number }) => string, +): string { + const diff = Math.floor((now - timestamp) / 1000); + if (diff < 60) return t("usage.justNow"); + if (diff < 3600) + return t("usage.minutesAgo", { count: Math.floor(diff / 60) }); + if (diff < 86400) + return t("usage.hoursAgo", { count: Math.floor(diff / 3600) }); + return t("usage.daysAgo", { count: Math.floor(diff / 86400) }); +} + +const SubscriptionQuotaFooter: React.FC = ({ + appId, + inline = false, +}) => { + const { t } = useTranslation(); + const { + data: quota, + isFetching: loading, + refetch, + } = useSubscriptionQuota(appId, true); + + // 定期更新相对时间显示 + const [now, setNow] = React.useState(Date.now()); + React.useEffect(() => { + if (!quota?.queriedAt) return; + const interval = setInterval(() => setNow(Date.now()), 30000); + return () => clearInterval(interval); + }, [quota?.queriedAt]); + + // 无凭据 → 不显示 + if (!quota || quota.credentialStatus === "not_found") return null; + + // 凭据解析错误 → 不显示(静默) + if (quota.credentialStatus === "parse_error") return null; + + // 凭据过期 + if (quota.credentialStatus === "expired" && !quota.success) { + if (inline) { + return ( +
+
+ + {t("subscription.expired")} +
+ +
+ ); + } + return ( +
+
+
+ +
+ {t("subscription.expired")} + + {t("subscription.expiredHint")} + +
+
+ +
+
+ ); + } + + // API 调用失败 + if (!quota.success) { + if (inline) { + return ( +
+
+ + {t("subscription.queryFailed")} +
+ +
+ ); + } + return ( +
+
+
+ + {quota.error || t("subscription.queryFailed")} +
+ +
+
+ ); + } + + // 成功获取数据 + const tiers = quota.tiers || []; + if (tiers.length === 0) return null; + + // ── inline 模式:紧凑两行显示 ── + if (inline) { + return ( +
+ {/* 第一行:查询时间 + 刷新 */} +
+ + + {quota.queriedAt + ? formatRelativeTime(quota.queriedAt, now, t) + : t("usage.never", { defaultValue: "从未更新" })} + + +
+ + {/* 第二行:各 tier 使用百分比 */} +
+ {tiers + .filter((tier) => !HIDDEN_INLINE_TIERS.has(tier.name)) + .map((tier) => ( + + ))} +
+
+ ); + } + + // ── 展开模式:详细信息 ── + return ( +
+
+ + {t("subscription.title", { defaultValue: "Subscription Quota" })} + +
+ {quota.queriedAt && ( + + + {formatRelativeTime(quota.queriedAt, now, t)} + + )} + +
+
+ +
+ {tiers.map((tier) => ( + + ))} +
+ + {/* 超额使用 */} + {quota.extraUsage?.isEnabled && ( +
+ {t("subscription.extraUsage")}: + + {quota.extraUsage.currency === "USD" ? "$" : ""} + {(quota.extraUsage.usedCredits ?? 0).toFixed(2)} + {quota.extraUsage.monthlyLimit != null && ( + <> + {" "} + / {quota.extraUsage.currency === "USD" ? "$" : ""} + {quota.extraUsage.monthlyLimit.toFixed(2)} + + )} + +
+ )} +
+ ); +}; + +/** inline 模式下的单个 tier 显示 */ +const TierBadge: React.FC<{ + tier: QuotaTier; + t: (key: string, options?: Record) => string; +}> = ({ tier, t }) => { + const label = TIER_I18N_KEYS[tier.name] + ? t(TIER_I18N_KEYS[tier.name]) + : tier.name; + const countdown = countdownStr(tier.resetsAt); + + return ( +
+ {label}: + + {t("subscription.utilization", { value: Math.round(tier.utilization) })} + + {countdown && ( + + + {countdown} + + )} +
+ ); +}; + +/** 展开模式下的单个 tier 进度条 */ +const TierBar: React.FC<{ + tier: QuotaTier; + t: (key: string, options?: Record) => string; +}> = ({ tier, t }) => { + const label = TIER_I18N_KEYS[tier.name] + ? t(TIER_I18N_KEYS[tier.name]) + : tier.name; + const resetText = formatResetTime(tier.resetsAt, t); + + return ( +
+ + {label} + + + {/* 进度条 */} +
+
= 90 + ? "bg-red-500" + : tier.utilization >= 70 + ? "bg-orange-500" + : "bg-green-500" + }`} + style={{ width: `${Math.min(tier.utilization, 100)}%` }} + /> +
+ +
+ + {Math.round(tier.utilization)}% + + {resetText && ( + + {resetText} + + )} +
+
+ ); +}; + +export default SubscriptionQuotaFooter; diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index ab7f3bf07..289e27b3f 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -11,6 +11,7 @@ import { cn } from "@/lib/utils"; import { ProviderActions } from "@/components/providers/ProviderActions"; import { ProviderIcon } from "@/components/ProviderIcon"; import UsageFooter from "@/components/UsageFooter"; +import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; @@ -55,6 +56,14 @@ interface ProviderCardProps { onSetAsDefault?: () => void; } +/** 判断是否为官方供应商(无自定义 base URL,直连官方 API) */ +function isOfficialProvider(provider: Provider, appId: AppId): boolean { + if (appId !== "claude") return false; + const baseUrl = (provider.settingsConfig as Record)?.env + ?.ANTHROPIC_BASE_URL; + return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""); +} + const extractApiUrl = (provider: Provider, fallbackText: string) => { if (provider.notes?.trim()) { return provider.notes.trim(); @@ -146,6 +155,7 @@ export function ProviderCard({ }, [provider.notes, displayUrl, fallbackUrlText]); const usageEnabled = provider.meta?.usage_script?.enabled ?? false; + const isOfficial = isOfficialProvider(provider, appId); // 获取用量数据以判断是否有多套餐 // 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent @@ -342,7 +352,9 @@ export function ProviderCard({ >
- {hasMultiplePlans ? ( + {isOfficial ? ( + + ) : hasMultiplePlans ? (
{t("usage.multiplePlans", { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ce540b17a..e3c0ffe5d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2276,5 +2276,19 @@ }, "suggestedDefaults": "Apply Suggested Defaults", "suggestedDefaultsHint": "Use this preset's recommended default model configuration" + }, + "subscription": { + "title": "Subscription Quota", + "fiveHour": "5-Hour", + "sevenDay": "7-Day", + "sevenDayOpus": "7-Day (Opus)", + "sevenDaySonnet": "7-Day (Sonnet)", + "utilization": "{{value}}%", + "resetsIn": "Resets in {{time}}", + "extraUsage": "Extra Usage", + "expired": "Session expired", + "expiredHint": "Run the claude command to refresh your login", + "queryFailed": "Query failed", + "refresh": "Refresh" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6ddc13dbc..46519e9f5 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2276,5 +2276,19 @@ }, "suggestedDefaults": "推奨デフォルト設定を適用", "suggestedDefaultsHint": "このプリセットの推奨デフォルトモデル設定を使用します" + }, + "subscription": { + "title": "サブスクリプションクォータ", + "fiveHour": "5時間", + "sevenDay": "7日間", + "sevenDayOpus": "7日間(Opus)", + "sevenDaySonnet": "7日間(Sonnet)", + "utilization": "{{value}}%", + "resetsIn": "{{time}}後にリセット", + "extraUsage": "超過使用量", + "expired": "セッション期限切れ", + "expiredHint": "claudeコマンドを実行してログインを更新してください", + "queryFailed": "クエリに失敗しました", + "refresh": "更新" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 61174120b..5d27d3e29 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2276,5 +2276,19 @@ }, "suggestedDefaults": "应用建议默认配置", "suggestedDefaultsHint": "使用此预设推荐的默认模型配置" + }, + "subscription": { + "title": "订阅额度", + "fiveHour": "5小时", + "sevenDay": "7天", + "sevenDayOpus": "7天(Opus)", + "sevenDaySonnet": "7天(Sonnet)", + "utilization": "{{value}}%", + "resetsIn": "{{time}}后重置", + "extraUsage": "超额用量", + "expired": "会话已过期", + "expiredHint": "请运行 claude 命令刷新登录", + "queryFailed": "查询失败", + "refresh": "刷新" } } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index ea971fba6..47458b8c3 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -6,6 +6,7 @@ export { mcpApi } from "./mcp"; export { promptsApi } from "./prompts"; export { skillsApi } from "./skills"; export { usageApi } from "./usage"; +export { subscriptionApi } from "./subscription"; export { vscodeApi } from "./vscode"; export { proxyApi } from "./proxy"; export { openclawApi } from "./openclaw"; diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts new file mode 100644 index 000000000..df7eaba43 --- /dev/null +++ b/src/lib/api/subscription.ts @@ -0,0 +1,7 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { SubscriptionQuota } from "@/types/subscription"; + +export const subscriptionApi = { + getQuota: (tool: string): Promise => + invoke("get_subscription_quota", { tool }), +}; diff --git a/src/lib/query/index.ts b/src/lib/query/index.ts index 223ec0327..a1a409a35 100644 --- a/src/lib/query/index.ts +++ b/src/lib/query/index.ts @@ -2,3 +2,4 @@ export * from "./queryClient"; export * from "./queries"; export * from "./mutations"; export * from "./proxy"; +export * from "./subscription"; diff --git a/src/lib/query/subscription.ts b/src/lib/query/subscription.ts new file mode 100644 index 000000000..c70545b77 --- /dev/null +++ b/src/lib/query/subscription.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import { subscriptionApi } from "@/lib/api/subscription"; +import type { AppId } from "@/lib/api/types"; + +const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes + +export function useSubscriptionQuota(appId: AppId, enabled: boolean) { + return useQuery({ + queryKey: ["subscription", "quota", appId], + queryFn: () => subscriptionApi.getQuota(appId), + enabled: enabled && ["claude", "codex", "gemini"].includes(appId), + refetchInterval: REFETCH_INTERVAL, + refetchOnWindowFocus: true, + staleTime: REFETCH_INTERVAL, + retry: 1, + }); +} diff --git a/src/types/subscription.ts b/src/types/subscription.ts new file mode 100644 index 000000000..3e1157caf --- /dev/null +++ b/src/types/subscription.ts @@ -0,0 +1,30 @@ +export type CredentialStatus = + | "valid" + | "expired" + | "not_found" + | "parse_error"; + +export interface QuotaTier { + name: string; + utilization: number; // 0-100 + resetsAt: string | null; +} + +export interface ExtraUsage { + isEnabled: boolean; + monthlyLimit: number | null; + usedCredits: number | null; + utilization: number | null; + currency: string | null; +} + +export interface SubscriptionQuota { + tool: string; + credentialStatus: CredentialStatus; + credentialMessage: string | null; + success: boolean; + tiers: QuotaTier[]; + extraUsage: ExtraUsage | null; + error: string | null; + queriedAt: number | null; +}