mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 09:37:37 +08:00
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:
@@ -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;
|
||||
@@ -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", {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "更新"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "刷新"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -2,3 +2,4 @@ export * from "./queryClient";
|
||||
export * from "./queries";
|
||||
export * from "./mutations";
|
||||
export * from "./proxy";
|
||||
export * from "./subscription";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user