From d33d63e4dfcba023401309ce00ecef9261a8bcff Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 23:21:02 +0800 Subject: [PATCH] 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. --- src/components/CopilotQuotaFooter.tsx | 186 +++++++++++++++++++++ src/components/SubscriptionQuotaFooter.tsx | 2 + src/components/providers/ProviderCard.tsx | 9 +- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh.json | 1 + src/lib/query/copilot.ts | 52 ++++++ 7 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/components/CopilotQuotaFooter.tsx create mode 100644 src/lib/query/copilot.ts diff --git a/src/components/CopilotQuotaFooter.tsx b/src/components/CopilotQuotaFooter.tsx new file mode 100644 index 000000000..60726c88a --- /dev/null +++ b/src/components/CopilotQuotaFooter.tsx @@ -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 = ({ + 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 ( +
+
+ + {quota.error || t("subscription.queryFailed")} +
+ +
+ ); + } + return null; + } + + const tiers = quota.tiers; + if (tiers.length === 0) return null; + + if (inline) { + return ( +
+
+ {quota.plan && ( + + {quota.plan} + + )} + + + {quota.queriedAt + ? formatRelativeTime(quota.queriedAt, now, t) + : t("usage.never", { defaultValue: "Never" })} + + +
+ +
+ {tiers.map((tier) => ( + + ))} +
+
+ ); + } + + // 展开模式 + return ( +
+
+ + {quota.plan || t("subscription.title")} + +
+ {quota.queriedAt && ( + + + {formatRelativeTime(quota.queriedAt, now, t)} + + )} + +
+
+ +
+ {tiers.map((tier) => { + const label = t("subscription.copilotPremium", { + defaultValue: "Premium", + }); + 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)}% + +
+ ); + })} +
+
+ ); +}; + +export default CopilotQuotaFooter; diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 32cb05489..4226544e3 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -22,6 +22,8 @@ export const TIER_I18N_KEYS: Record = { gemini_flash_lite: "subscription.geminiFlashLite", // Token Plan(five_hour 已在上方官方映射中) weekly_limit: "subscription.weeklyLimit", + // GitHub Copilot + premium: "subscription.copilotPremium", }; /** 根据使用百分比返回颜色 class */ diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index cb450fa97..52f662a92 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -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({
- {isOfficial ? ( + {isCopilot ? ( + + ) : isOfficial ? ( ) : hasMultiplePlans ? (
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f5e4b850b..dc6eb02c5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2353,6 +2353,7 @@ "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", "weeklyLimit": "Weekly", + "copilotPremium": "Premium", "utilization": "{{value}}%", "resetsIn": "Resets in {{time}}", "extraUsage": "Extra Usage", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8f7852655..47b08db43 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2353,6 +2353,7 @@ "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", "weeklyLimit": "週間", + "copilotPremium": "プレミアム", "utilization": "{{value}}%", "resetsIn": "{{time}}後にリセット", "extraUsage": "超過使用量", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6ec175cd6..aaa944ab9 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2353,6 +2353,7 @@ "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", "weeklyLimit": "每周", + "copilotPremium": "高级请求", "utilization": "{{value}}%", "resetsIn": "{{time}}后重置", "extraUsage": "超额用量", diff --git a/src/lib/query/copilot.ts b/src/lib/query/copilot.ts new file mode 100644 index 000000000..fd1b58f25 --- /dev/null +++ b/src/lib/query/copilot.ts @@ -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({ + queryKey: ["copilot", "quota", accountId ?? "default"], + queryFn: async (): Promise => { + 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, + }); +}