mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
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:
@@ -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 Plan(five_hour 已在上方官方映射中)
|
||||
weekly_limit: "subscription.weeklyLimit",
|
||||
// GitHub Copilot
|
||||
premium: "subscription.copilotPremium",
|
||||
};
|
||||
|
||||
/** 根据使用百分比返回颜色 class */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -2353,6 +2353,7 @@
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "Weekly",
|
||||
"copilotPremium": "Premium",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "Resets in {{time}}",
|
||||
"extraUsage": "Extra Usage",
|
||||
|
||||
@@ -2353,6 +2353,7 @@
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "週間",
|
||||
"copilotPremium": "プレミアム",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}}後にリセット",
|
||||
"extraUsage": "超過使用量",
|
||||
|
||||
@@ -2353,6 +2353,7 @@
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "每周",
|
||||
"copilotPremium": "高级请求",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}}后重置",
|
||||
"extraUsage": "超额用量",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user