mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-03 11:01:22 +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",
|
gemini_flash_lite: "subscription.geminiFlashLite",
|
||||||
// Token Plan(five_hour 已在上方官方映射中)
|
// Token Plan(five_hour 已在上方官方映射中)
|
||||||
weekly_limit: "subscription.weeklyLimit",
|
weekly_limit: "subscription.weeklyLimit",
|
||||||
|
// GitHub Copilot
|
||||||
|
premium: "subscription.copilotPremium",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 根据使用百分比返回颜色 class */
|
/** 根据使用百分比返回颜色 class */
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { ProviderActions } from "@/components/providers/ProviderActions";
|
|||||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||||
import UsageFooter from "@/components/UsageFooter";
|
import UsageFooter from "@/components/UsageFooter";
|
||||||
import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter";
|
import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter";
|
||||||
|
import CopilotQuotaFooter from "@/components/CopilotQuotaFooter";
|
||||||
|
import { PROVIDER_TYPES } from "@/config/constants";
|
||||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||||
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
|
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
|
||||||
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||||
@@ -172,6 +174,9 @@ export function ProviderCard({
|
|||||||
|
|
||||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||||
const isOfficial = isOfficialProvider(provider, appId);
|
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
|
// 累加模式应用(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="flex items-center ml-auto min-w-0 gap-3">
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{isOfficial ? (
|
{isCopilot ? (
|
||||||
|
<CopilotQuotaFooter meta={provider.meta} inline={true} />
|
||||||
|
) : isOfficial ? (
|
||||||
<SubscriptionQuotaFooter appId={appId} inline={true} />
|
<SubscriptionQuotaFooter appId={appId} inline={true} />
|
||||||
) : hasMultiplePlans ? (
|
) : hasMultiplePlans ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
|||||||
@@ -2353,6 +2353,7 @@
|
|||||||
"geminiFlash": "Flash",
|
"geminiFlash": "Flash",
|
||||||
"geminiFlashLite": "Flash Lite",
|
"geminiFlashLite": "Flash Lite",
|
||||||
"weeklyLimit": "Weekly",
|
"weeklyLimit": "Weekly",
|
||||||
|
"copilotPremium": "Premium",
|
||||||
"utilization": "{{value}}%",
|
"utilization": "{{value}}%",
|
||||||
"resetsIn": "Resets in {{time}}",
|
"resetsIn": "Resets in {{time}}",
|
||||||
"extraUsage": "Extra Usage",
|
"extraUsage": "Extra Usage",
|
||||||
|
|||||||
@@ -2353,6 +2353,7 @@
|
|||||||
"geminiFlash": "Flash",
|
"geminiFlash": "Flash",
|
||||||
"geminiFlashLite": "Flash Lite",
|
"geminiFlashLite": "Flash Lite",
|
||||||
"weeklyLimit": "週間",
|
"weeklyLimit": "週間",
|
||||||
|
"copilotPremium": "プレミアム",
|
||||||
"utilization": "{{value}}%",
|
"utilization": "{{value}}%",
|
||||||
"resetsIn": "{{time}}後にリセット",
|
"resetsIn": "{{time}}後にリセット",
|
||||||
"extraUsage": "超過使用量",
|
"extraUsage": "超過使用量",
|
||||||
|
|||||||
@@ -2353,6 +2353,7 @@
|
|||||||
"geminiFlash": "Flash",
|
"geminiFlash": "Flash",
|
||||||
"geminiFlashLite": "Flash Lite",
|
"geminiFlashLite": "Flash Lite",
|
||||||
"weeklyLimit": "每周",
|
"weeklyLimit": "每周",
|
||||||
|
"copilotPremium": "高级请求",
|
||||||
"utilization": "{{value}}%",
|
"utilization": "{{value}}%",
|
||||||
"resetsIn": "{{time}}后重置",
|
"resetsIn": "{{time}}后重置",
|
||||||
"extraUsage": "超额用量",
|
"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