feat: display Copilot premium interactions quota on provider card

Copilot usage query API was implemented but never surfaced on the main
provider list. Add CopilotQuotaFooter component that auto-detects
github_copilot providers and displays premium interaction utilization
inline, reusing the existing TierBadge UI from SubscriptionQuotaFooter.
This commit is contained in:
Jason
2026-04-06 23:21:02 +08:00
Unverified
parent eb41e1052c
commit d33d63e4df
7 changed files with 251 additions and 1 deletions
+186
View File
@@ -0,0 +1,186 @@
import React from "react";
import { RefreshCw, AlertCircle, Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ProviderMeta } from "@/types";
import { useCopilotQuota } from "@/lib/query/copilot";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { PROVIDER_TYPES } from "@/config/constants";
import {
TierBadge,
utilizationColor,
} from "@/components/SubscriptionQuotaFooter";
interface CopilotQuotaFooterProps {
meta?: ProviderMeta;
inline?: boolean;
}
/** 格式化相对时间 */
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 CopilotQuotaFooter: React.FC<CopilotQuotaFooterProps> = ({
meta,
inline = false,
}) => {
const { t } = useTranslation();
const accountId = resolveManagedAccountId(
meta,
PROVIDER_TYPES.GITHUB_COPILOT,
);
const {
data: quota,
isFetching: loading,
refetch,
} = useCopilotQuota(accountId, 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) return null;
// 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>{quota.error || 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 null;
}
const tiers = quota.tiers;
if (tiers.length === 0) return null;
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">
{quota.plan && (
<span className="text-[10px] text-muted-foreground/70">
{quota.plan}
</span>
)}
<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: "Never" })}
</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>
<div className="flex items-center gap-2">
{tiers.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">
{quota.plan || t("subscription.title")}
</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) => {
const label = t("subscription.copilotPremium", {
defaultValue: "Premium",
});
return (
<div key={tier.name} 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>
<span
className={`font-semibold tabular-nums ${utilizationColor(tier.utilization)}`}
>
{Math.round(tier.utilization)}%
</span>
</div>
);
})}
</div>
</div>
);
};
export default CopilotQuotaFooter;
@@ -22,6 +22,8 @@ export const TIER_I18N_KEYS: Record<string, string> = {
gemini_flash_lite: "subscription.geminiFlashLite",
// Token Planfive_hour 已在上方官方映射中)
weekly_limit: "subscription.weeklyLimit",
// GitHub Copilot
premium: "subscription.copilotPremium",
};
/** 根据使用百分比返回颜色 class */
+8 -1
View File
@@ -12,6 +12,8 @@ import { ProviderActions } from "@/components/providers/ProviderActions";
import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter";
import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter";
import CopilotQuotaFooter from "@/components/CopilotQuotaFooter";
import { PROVIDER_TYPES } from "@/config/constants";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
@@ -172,6 +174,9 @@ export function ProviderCard({
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
const isOfficial = isOfficialProvider(provider, appId);
const isCopilot =
provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT ||
provider.meta?.usage_script?.templateType === "github_copilot";
// 获取用量数据以判断是否有多套餐
// 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent
@@ -348,7 +353,9 @@ export function ProviderCard({
<div className="flex items-center ml-auto min-w-0 gap-3">
<div className="ml-auto">
<div className="flex items-center gap-1">
{isOfficial ? (
{isCopilot ? (
<CopilotQuotaFooter meta={provider.meta} inline={true} />
) : isOfficial ? (
<SubscriptionQuotaFooter appId={appId} inline={true} />
) : hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
+1
View File
@@ -2353,6 +2353,7 @@
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "Weekly",
"copilotPremium": "Premium",
"utilization": "{{value}}%",
"resetsIn": "Resets in {{time}}",
"extraUsage": "Extra Usage",
+1
View File
@@ -2353,6 +2353,7 @@
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "週間",
"copilotPremium": "プレミアム",
"utilization": "{{value}}%",
"resetsIn": "{{time}}後にリセット",
"extraUsage": "超過使用量",
+1
View File
@@ -2353,6 +2353,7 @@
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "每周",
"copilotPremium": "高级请求",
"utilization": "{{value}}%",
"resetsIn": "{{time}}后重置",
"extraUsage": "超额用量",
+52
View File
@@ -0,0 +1,52 @@
import { useQuery } from "@tanstack/react-query";
import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot";
import type { QuotaTier } from "@/types/subscription";
const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes
export interface CopilotQuota {
success: boolean;
plan: string | null;
resetDate: string | null;
tiers: QuotaTier[];
error: string | null;
queriedAt: number | null;
}
export function useCopilotQuota(accountId: string | null, enabled: boolean) {
return useQuery<CopilotQuota>({
queryKey: ["copilot", "quota", accountId ?? "default"],
queryFn: async (): Promise<CopilotQuota> => {
const usage = accountId
? await copilotGetUsageForAccount(accountId)
: await copilotGetUsage();
const premium = usage.quota_snapshots.premium_interactions;
const utilization =
premium.entitlement > 0
? ((premium.entitlement - premium.remaining) / premium.entitlement) *
100
: 0;
return {
success: true,
plan: usage.copilot_plan,
resetDate: usage.quota_reset_date,
tiers: [
{
name: "premium",
utilization,
resetsAt: usage.quota_reset_date,
},
],
error: null,
queriedAt: Date.now(),
};
},
enabled,
refetchInterval: REFETCH_INTERVAL,
refetchOnWindowFocus: true,
staleTime: REFETCH_INTERVAL,
retry: 1,
});
}