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, SubscriptionQuota } from "@/types/subscription"; interface SubscriptionQuotaFooterProps { appId: AppId; inline?: boolean; isCurrent?: boolean; } interface SubscriptionQuotaViewProps { quota: SubscriptionQuota | undefined; loading: boolean; refetch: () => void; /** 用于 `subscription.expiredHint` 的 {tool} 插值;解耦了 hook 的 appId */ appIdForExpiredHint: string; inline?: boolean; } /** 已知 tier 名称的显示映射(官方订阅 + Token Plan 共用) */ export const TIER_I18N_KEYS: Record = { five_hour: "subscription.fiveHour", seven_day: "subscription.sevenDay", seven_day_opus: "subscription.sevenDayOpus", seven_day_sonnet: "subscription.sevenDaySonnet", // Gemini 模型分类 gemini_pro: "subscription.geminiPro", gemini_flash: "subscription.geminiFlash", gemini_flash_lite: "subscription.geminiFlashLite", // Token Plan(five_hour 已在上方官方映射中) weekly_limit: "subscription.sevenDay", // GitHub Copilot premium: "subscription.copilotPremium", }; /** 根据使用百分比返回颜色 class */ export 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" */ export 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 | 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) }); } /** * 纯展示组件:渲染 SubscriptionQuota 的 5 种状态(not_found / parse_error / * expired / API 失败 / 成功),支持 inline / expanded 两种布局。 * * 数据源由调用方 hook 注入,方便不同的额度后端复用同一套渲染逻辑: * - `SubscriptionQuotaFooter`(CLI 凭据路径,by appId) * - `CodexOauthQuotaFooter`(cc-switch 自管 OAuth 路径,by ChatGPT account) */ export const SubscriptionQuotaView: React.FC = ({ quota, loading, refetch, appIdForExpiredHint, inline = false, }) => { const { t } = useTranslation(); // 定期更新相对时间显示 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 (
{t("subscription.expired")}
); } return (
{t("subscription.expired")} {t("subscription.expiredHint", { tool: appIdForExpiredHint })}
); } // API 调用失败 if (!quota.success) { if (inline) { return (
{t("subscription.queryFailed")}
); } return (
{quota.error || t("subscription.queryFailed")}
); } // 成功获取数据 const tiers = (quota.tiers || []).filter( (tier) => tier.name in TIER_I18N_KEYS, ); if (tiers.length === 0) return null; // ── inline 模式:紧凑两行显示 ── if (inline) { return (
{/* 第一行:查询时间 + 刷新 */}
{quota.queriedAt ? formatRelativeTime(quota.queriedAt, now, t) : t("usage.never", { defaultValue: "从未更新" })}
{/* 第二行:各 tier 使用百分比 */}
{tiers .filter((tier) => !HIDDEN_INLINE_TIERS.has(tier.name)) .map((tier) => ( ))}
); } // ── 展开模式:详细信息 ── return (
{t("subscription.title", { defaultValue: "Subscription Quota" })}
{quota.queriedAt && ( {formatRelativeTime(quota.queriedAt, now, t)} )}
{tiers.map((tier) => ( ))}
{/* 超额使用 */} {quota.extraUsage?.isEnabled && (
{t("subscription.extraUsage")}: {quota.extraUsage.currency === "USD" ? "$" : ""} {(quota.extraUsage.usedCredits ?? 0).toFixed(2)} {quota.extraUsage.monthlyLimit != null && ( <> {" "} / {quota.extraUsage.currency === "USD" ? "$" : ""} {quota.extraUsage.monthlyLimit.toFixed(2)} )}
)}
); }; /** inline 模式下的单个 tier 显示 */ export const TierBadge: React.FC<{ tier: QuotaTier; t: (key: string, options?: Record) => string; }> = ({ tier, t }) => { const label = TIER_I18N_KEYS[tier.name] ? t(TIER_I18N_KEYS[tier.name]) : tier.name; const countdown = countdownStr(tier.resetsAt); return (
{label}: {t("subscription.utilization", { value: Math.round(tier.utilization) })} {countdown && ( {countdown} )}
); }; /** 展开模式下的单个 tier 进度条 */ const TierBar: React.FC<{ tier: QuotaTier; t: (key: string, options?: Record) => 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 (
{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)}% {resetText && ( {resetText} )}
); }; /** * CLI 凭据路径下的薄 wrapper:通过 useSubscriptionQuota(appId) 自取数据 * 后转发到 SubscriptionQuotaView。对外 props/行为与重构前完全一致。 */ const SubscriptionQuotaFooter: React.FC = ({ appId, inline = false, isCurrent = false, }) => { const { data: quota, isFetching: loading, refetch, } = useSubscriptionQuota(appId, isCurrent, isCurrent); if (!isCurrent) return null; return ( ); }; export default SubscriptionQuotaFooter;