feat: display official subscription quota on Claude provider cards

Read Claude OAuth credentials from macOS Keychain (with file fallback)
and query the Anthropic usage API to show quota utilization inline on
official provider cards. Includes compact countdown timer for reset
windows and hides the rarely-used seven_day_sonnet tier in inline mode.
This commit is contained in:
Jason
2026-04-04 17:18:42 +08:00
parent d9c0e4c452
commit b30f3c27ad
15 changed files with 945 additions and 1 deletions
+2
View File
@@ -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::*;
+10
View File
@@ -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<SubscriptionQuota, String> {
crate::services::subscription::get_subscription_quota(&tool).await
}
+2
View File
@@ -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,
+1
View File
@@ -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;
+460
View File
@@ -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,
/// 使用百分比 0100
pub utilization: f64,
/// ISO 8601 重置时间
pub resets_at: Option<String>,
}
/// 超额使用信息
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtraUsage {
pub is_enabled: bool,
pub monthly_limit: Option<f64>,
pub used_credits: Option<f64>,
pub utilization: Option<f64>,
pub currency: Option<String>,
}
/// 订阅额度查询结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionQuota {
pub tool: String,
pub credential_status: CredentialStatus,
pub credential_message: Option<String>,
pub success: bool,
pub tiers: Vec<QuotaTier>,
pub extra_usage: Option<ExtraUsage>,
pub error: Option<String>,
pub queried_at: Option<i64>,
}
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<String>,
#[serde(rename = "expiresAt")]
expires_at: Option<serde_json::Value>,
}
/// 读取 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<String>, CredentialStatus, Option<String>) {
// 来源 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<String>, CredentialStatus, Option<String>)> {
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<String>, CredentialStatus, Option<String>) {
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 凭据 JSONKeychain 和文件共用)
fn parse_claude_credentials_json(
content: &str,
) -> (Option<String>, CredentialStatus, Option<String>) {
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<f64>,
resets_at: Option<String>,
}
/// Claude OAuth 用量 API 响应中的超额用量
#[derive(Deserialize)]
struct ApiExtraUsage {
is_enabled: Option<bool>,
monthly_limit: Option<f64>,
used_credits: Option<f64>,
utilization: Option<f64>,
currency: Option<String>,
}
/// 已知的 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::<ApiUsageWindow>(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::<ApiUsageWindow>(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::<ApiExtraUsage>(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<SubscriptionQuota, String> {
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
}
+359
View File
@@ -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<string, string> = {
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>) => 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<SubscriptionQuotaFooterProps> = ({
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 (
<div className="inline-flex items-center gap-2 text-xs rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 shadow-sm">
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
<AlertCircle size={12} />
<span>{t("subscription.expired")}</span>
</div>
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0"
title={t("subscription.refresh")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
);
}
return (
<div className="mt-3 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-4 py-3 shadow-sm">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertCircle size={14} />
<div>
<span className="font-medium">{t("subscription.expired")}</span>
<span className="ml-2 text-amber-500/70 dark:text-amber-400/70">
{t("subscription.expiredHint")}
</span>
</div>
</div>
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-amber-100 dark:hover:bg-amber-800/30 transition-colors disabled:opacity-50 flex-shrink-0"
title={t("subscription.refresh")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
</div>
);
}
// API 调用失败
if (!quota.success) {
if (inline) {
return (
<div className="inline-flex items-center gap-2 text-xs rounded-lg border border-border-default bg-card px-3 py-2 shadow-sm">
<div className="flex items-center gap-1.5 text-red-500 dark:text-red-400">
<AlertCircle size={12} />
<span>{t("subscription.queryFailed")}</span>
</div>
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0"
title={t("subscription.refresh")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
);
}
return (
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 text-red-500 dark:text-red-400">
<AlertCircle size={14} />
<span>{quota.error || t("subscription.queryFailed")}</span>
</div>
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
title={t("subscription.refresh")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
</div>
);
}
// 成功获取数据
const tiers = quota.tiers || [];
if (tiers.length === 0) return null;
// ── inline 模式:紧凑两行显示 ──
if (inline) {
return (
<div className="flex flex-col items-end gap-1 text-xs whitespace-nowrap flex-shrink-0">
{/* 第一行:查询时间 + 刷新 */}
<div className="flex items-center gap-2 justify-end">
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<Clock size={10} />
{quota.queriedAt
? formatRelativeTime(quota.queriedAt, now, t)
: t("usage.never", { defaultValue: "从未更新" })}
</span>
<button
onClick={(e) => {
e.stopPropagation();
refetch();
}}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground"
title={t("subscription.refresh")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
{/* 第二行:各 tier 使用百分比 */}
<div className="flex items-center gap-2">
{tiers
.filter((tier) => !HIDDEN_INLINE_TIERS.has(tier.name))
.map((tier) => (
<TierBadge key={tier.name} tier={tier} t={t} />
))}
</div>
</div>
);
}
// ── 展开模式:详细信息 ──
return (
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{t("subscription.title", { defaultValue: "Subscription Quota" })}
</span>
<div className="flex items-center gap-2">
{quota.queriedAt && (
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(quota.queriedAt, now, t)}
</span>
)}
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50"
title={t("subscription.refresh")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
</div>
<div className="flex flex-col gap-2">
{tiers.map((tier) => (
<TierBar key={tier.name} tier={tier} t={t} />
))}
</div>
{/* 超额使用 */}
{quota.extraUsage?.isEnabled && (
<div className="mt-2 pt-2 border-t border-border-default text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium">{t("subscription.extraUsage")}: </span>
<span className="tabular-nums">
{quota.extraUsage.currency === "USD" ? "$" : ""}
{(quota.extraUsage.usedCredits ?? 0).toFixed(2)}
{quota.extraUsage.monthlyLimit != null && (
<>
{" "}
/ {quota.extraUsage.currency === "USD" ? "$" : ""}
{quota.extraUsage.monthlyLimit.toFixed(2)}
</>
)}
</span>
</div>
)}
</div>
);
};
/** inline 模式下的单个 tier 显示 */
const TierBadge: React.FC<{
tier: QuotaTier;
t: (key: string, options?: Record<string, unknown>) => string;
}> = ({ tier, t }) => {
const label = TIER_I18N_KEYS[tier.name]
? t(TIER_I18N_KEYS[tier.name])
: tier.name;
const countdown = countdownStr(tier.resetsAt);
return (
<div className="flex items-center gap-0.5">
<span className="text-gray-500 dark:text-gray-400">{label}:</span>
<span
className={`font-semibold tabular-nums ${utilizationColor(tier.utilization)}`}
>
{t("subscription.utilization", { value: Math.round(tier.utilization) })}
</span>
{countdown && (
<span className="text-muted-foreground/60 ml-0.5 flex items-center gap-px">
<Clock size={10} />
{countdown}
</span>
)}
</div>
);
};
/** 展开模式下的单个 tier 进度条 */
const TierBar: React.FC<{
tier: QuotaTier;
t: (key: string, options?: Record<string, unknown>) => 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 (
<div className="flex items-center gap-3 text-xs">
<span
className="text-gray-500 dark:text-gray-400 min-w-0 font-medium"
style={{ width: "25%" }}
>
{label}
</span>
{/* 进度条 */}
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
tier.utilization >= 90
? "bg-red-500"
: tier.utilization >= 70
? "bg-orange-500"
: "bg-green-500"
}`}
style={{ width: `${Math.min(tier.utilization, 100)}%` }}
/>
</div>
<div
className="flex items-center gap-2 flex-shrink-0"
style={{ width: "30%" }}
>
<span
className={`font-semibold tabular-nums ${utilizationColor(tier.utilization)}`}
>
{Math.round(tier.utilization)}%
</span>
{resetText && (
<span
className="text-[10px] text-muted-foreground/70 truncate"
title={resetText}
>
{resetText}
</span>
)}
</div>
</div>
);
};
export default SubscriptionQuotaFooter;
+13 -1
View File
@@ -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<string, any>)?.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({
>
<div className="ml-auto">
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
{hasMultiplePlans ? (
{isOfficial ? (
<SubscriptionQuotaFooter appId={appId} inline={true} />
) : hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">
{t("usage.multiplePlans", {
+14
View File
@@ -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"
}
}
+14
View File
@@ -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": "更新"
}
}
+14
View File
@@ -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": "刷新"
}
}
+1
View File
@@ -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";
+7
View File
@@ -0,0 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import type { SubscriptionQuota } from "@/types/subscription";
export const subscriptionApi = {
getQuota: (tool: string): Promise<SubscriptionQuota> =>
invoke("get_subscription_quota", { tool }),
};
+1
View File
@@ -2,3 +2,4 @@ export * from "./queryClient";
export * from "./queries";
export * from "./mutations";
export * from "./proxy";
export * from "./subscription";
+17
View File
@@ -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,
});
}
+30
View File
@@ -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;
}