Files
CC-Switch/src/components/usage/UsageDashboard.tsx
T
2026-08-02 22:52:35 +00:00

521 lines
19 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { UsageHero } from "./UsageHero";
import { UsageTrendChart } from "./UsageTrendChart";
import { RequestLogTable } from "./RequestLogTable";
import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import {
KNOWN_APP_TYPES,
type AppType,
type AppTypeFilter,
type UsageRangeSelection,
} from "@/types/usage";
import { motion } from "framer-motion";
import {
BarChart3,
ListFilter,
Activity,
RefreshCw,
Coins,
LayoutGrid,
DatabaseBackup,
Loader2,
} from "lucide-react";
import { ProviderIcon } from "@/components/ProviderIcon";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useQueryClient } from "@tanstack/react-query";
import { usageKeys, useModelStats, useProviderStats } from "@/lib/query/usage";
import { useUsageEventBridge } from "@/hooks/useUsageEventBridge";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { cn } from "@/lib/utils";
import { getLocaleFromLanguage } from "./format";
import { getUsageRangePresetLabel, resolveUsageRange } from "@/lib/usageRange";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { usageApi } from "@/lib/api/usage";
import { toast } from "sonner";
const APP_FILTER_OPTIONS: AppTypeFilter[] = ["all", ...KNOWN_APP_TYPES];
const DEFAULT_REFRESH_INTERVAL_MS = 30000;
const REFRESH_INTERVAL_OPTIONS_MS = [0, 5000, 10000, 30000, 60000] as const;
type RefreshIntervalOption = (typeof REFRESH_INTERVAL_OPTIONS_MS)[number];
const isRefreshIntervalOption = (
value: number | undefined,
): value is RefreshIntervalOption =>
REFRESH_INTERVAL_OPTIONS_MS.includes(value as RefreshIntervalOption);
const normalizeRefreshInterval = (value: number | undefined) =>
isRefreshIntervalOption(value) ? value : DEFAULT_REFRESH_INTERVAL_MS;
// 与 AppSwitcher 的 appIconName 保持一致(codex 复用 openai 图标)
const APP_FILTER_ICON: Record<AppType, string> = {
claude: "claude",
codex: "openai",
gemini: "gemini",
grokbuild: "grok",
opencode: "opencode",
pi: "pi",
};
// Select 的 "all" 哨兵和用户自定义名称同处一个值域——真有来源/模型叫 "all"
// 就会撞名(重复 value、选中即清空筛选)。动态选项统一加前缀编码隔离值域。
const DYNAMIC_OPTION_PREFIX = "v:";
const encodeOptionValue = (name: string) => `${DYNAMIC_OPTION_PREFIX}${name}`;
const decodeOptionValue = (value: string) =>
value === "all" ? undefined : value.slice(DYNAMIC_OPTION_PREFIX.length);
interface UsageDashboardProps {
refreshIntervalMs?: number;
onRefreshIntervalChange?: (next: number) => Promise<boolean> | boolean | void;
}
export function UsageDashboard({
refreshIntervalMs: savedRefreshIntervalMs,
onRefreshIntervalChange,
}: UsageDashboardProps = {}) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [range, setRange] = useState<UsageRangeSelection>({ preset: "today" });
const [appType, setAppType] = useState<AppTypeFilter>("all");
const [providerName, setProviderName] = useState<string | undefined>(
undefined,
);
const [model, setModel] = useState<string | undefined>(undefined);
const [refreshIntervalMs, setRefreshIntervalMs] = useState(() =>
normalizeRefreshInterval(savedRefreshIntervalMs),
);
const [showRebuildConfirm, setShowRebuildConfirm] = useState(false);
const [rebuildingCodex, setRebuildingCodex] = useState(false);
useEffect(() => {
setRefreshIntervalMs(normalizeRefreshInterval(savedRefreshIntervalMs));
}, [savedRefreshIntervalMs]);
// 切应用时清掉下游筛选,避免留下一个在新范围内查无数据的"幽灵"组合;
// 切 Provider 同理清掉模型(模型选项随 Provider 级联)。
const changeAppType = (next: AppTypeFilter) => {
setAppType(next);
if (next !== appType) {
setProviderName(undefined);
setModel(undefined);
}
};
const changeProviderName = (next: string | undefined) => {
setProviderName(next);
if (next !== providerName) {
setModel(undefined);
}
};
// 后端写入新日志时 emit `usage-log-recorded`,本 hook 立刻 invalidate 所有
// usage 查询,实现实时刷新(仅在 Dashboard 挂载时生效,离开页面自动取消监听)
useUsageEventBridge();
const changeRefreshInterval = async (next: number) => {
const normalized = normalizeRefreshInterval(next);
const previous = refreshIntervalMs;
setRefreshIntervalMs(normalized);
queryClient.invalidateQueries({ queryKey: usageKeys.all });
try {
const saved = await onRefreshIntervalChange?.(normalized);
if (saved === false) {
setRefreshIntervalMs(previous);
}
} catch (error) {
console.error(
"[UsageDashboard] Failed to persist refresh interval",
error,
);
setRefreshIntervalMs(previous);
}
};
const rebuildCodexUsage = async () => {
setShowRebuildConfirm(false);
setRebuildingCodex(true);
try {
const result = await usageApi.rebuildCodexUsage();
await queryClient.invalidateQueries({ queryKey: usageKeys.all });
const message = t("usage.rebuildCodex.completed", {
imported: result.imported,
errors: result.errors.length,
suspected: result.suspectedDuplicates,
deferred: result.deferredFiles,
});
if (result.errors.length > 0 || result.deferredFiles > 0) {
toast.warning(message);
} else {
toast.success(message);
}
} catch (error) {
toast.error(
t("usage.rebuildCodex.failed", {
error: String(error),
}),
);
} finally {
setRebuildingCodex(false);
}
};
const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language);
const resolvedRange = useMemo(() => resolveUsageRange(range), [range]);
const rangeLabel = useMemo(() => {
if (range.preset !== "custom") {
return getUsageRangePresetLabel(range.preset, t);
}
const startStr = new Date(resolvedRange.startDate * 1000).toLocaleString(
locale,
);
if (range.liveEndTime) {
return `${startStr}${t("usage.liveEndTimeNow", "现在")}`;
}
const endStr = new Date(resolvedRange.endDate * 1000).toLocaleString(
locale,
);
return `${startStr} - ${endStr}`;
}, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]);
// 顶栏下拉的选项池:Provider 列表只跟应用/时间范围走(不受自身选中值影响),
// 模型列表随所选 Provider 级联。两者都只列当前范围内真实有数据的条目。
// refetchInterval 必须跟随面板的刷新设置——未筛选时这两个查询与统计表共享
// query key,落下的话会以默认 30s 拖着同 key 查询一起轮询,"--" 形同虚设。
const optionsRefetch = {
refetchInterval:
refreshIntervalMs > 0 ? refreshIntervalMs : (false as const),
};
const { data: providerOptionsData } = useProviderStats(
range,
{ appType },
optionsRefetch,
);
const { data: modelOptionsData } = useModelStats(
range,
{ appType, providerName },
optionsRefetch,
);
const providerOptions = useMemo(() => {
const names = new Set<string>();
for (const stat of providerOptionsData ?? []) {
names.add(stat.providerName);
}
// 数据刷新后选中项可能掉出列表(如改了时间范围);补回去保证 Select
// 仍能渲染选中文案,用户看得见才能主动清除。
if (providerName) names.add(providerName);
return Array.from(names);
}, [providerOptionsData, providerName]);
const modelOptions = useMemo(() => {
const names = new Set<string>();
for (const stat of modelOptionsData ?? []) {
names.add(stat.model);
}
if (model) names.add(model);
return Array.from(names);
}, [modelOptionsData, model]);
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="space-y-8 pb-8"
>
<div className="flex flex-col lg:flex-row lg:items-end justify-between gap-4 mb-2">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold tracking-tight">
{t("usage.title")}
</h2>
<p className="text-sm text-muted-foreground">{t("usage.subtitle")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center p-1 bg-muted/30 rounded-lg border border-border/50">
{APP_FILTER_OPTIONS.map((type) => {
const label = t(`usage.appFilter.${type}`);
return (
<button
key={type}
type="button"
onClick={() => changeAppType(type)}
title={label}
aria-label={label}
className={cn(
"flex h-8 items-center justify-center px-2.5 rounded-md transition-all",
appType === type
? "bg-background text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>
{type === "all" ? (
<LayoutGrid className="h-4 w-4" />
) : (
<ProviderIcon
icon={APP_FILTER_ICON[type]}
name={label}
size={16}
/>
)}
</button>
);
})}
</div>
<Select
value={
providerName != null ? encodeOptionValue(providerName) : "all"
}
onValueChange={(v) => changeProviderName(decodeOptionValue(v))}
>
<SelectTrigger
className="h-9 w-[100px] bg-background text-xs focus:border-border-default [&>span]:min-w-0 [&>span]:truncate"
title={providerName ?? t("usage.filterBySource")}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-w-[280px]">
<SelectItem value="all">{t("usage.allSources")}</SelectItem>
{providerOptions.map((name) => (
<SelectItem
key={name}
value={encodeOptionValue(name)}
title={name}
className="[&>span]:min-w-0 [&>span]:truncate"
>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={model != null ? encodeOptionValue(model) : "all"}
onValueChange={(v) => setModel(decodeOptionValue(v))}
>
<SelectTrigger
className="h-9 w-[100px] bg-background text-xs focus:border-border-default [&>span]:min-w-0 [&>span]:truncate"
title={model ?? t("usage.filterByModel")}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-w-[280px]">
<SelectItem value="all">{t("usage.allModels")}</SelectItem>
{modelOptions.map((name) => (
<SelectItem
key={name}
value={encodeOptionValue(name)}
title={name}
className="[&>span]:min-w-0 [&>span]:truncate"
>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2 ml-auto lg:ml-0">
<Select
value={String(refreshIntervalMs)}
onValueChange={(v) => changeRefreshInterval(Number(v))}
>
<SelectTrigger
className="h-9 w-[100px] bg-background text-xs focus:border-border-default"
title={t("usage.refreshInterval")}
aria-label={t("usage.refreshInterval")}
>
<span className="flex items-center gap-2">
<RefreshCw className="h-3.5 w-3.5 shrink-0" />
<SelectValue />
</span>
</SelectTrigger>
<SelectContent>
{REFRESH_INTERVAL_OPTIONS_MS.map((ms) => (
<SelectItem key={ms} value={String(ms)}>
{ms > 0 ? `${ms / 1000}s` : t("usage.refreshOff")}
</SelectItem>
))}
</SelectContent>
</Select>
<UsageDateRangePicker
selection={range}
triggerLabel={rangeLabel}
onApply={(nextRange) => setRange(nextRange)}
/>
</div>
</div>
</div>
<UsageHero
range={range}
appType={appType === "all" ? undefined : appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
<UsageTrendChart
range={range}
rangeLabel={rangeLabel}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
<div className="space-y-4">
<Tabs defaultValue="logs" className="w-full">
<div className="flex items-center justify-between mb-4">
<TabsList className="bg-muted/50">
<TabsTrigger value="logs" className="gap-2">
<ListFilter className="h-4 w-4" />
{t("usage.requestLogs")}
</TabsTrigger>
<TabsTrigger value="providers" className="gap-2">
<Activity className="h-4 w-4" />
{t("usage.providerStats")}
</TabsTrigger>
<TabsTrigger value="models" className="gap-2">
<BarChart3 className="h-4 w-4" />
{t("usage.modelStats")}
</TabsTrigger>
</TabsList>
</div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<TabsContent value="logs" className="mt-0">
<RequestLogTable
range={range}
rangeLabel={rangeLabel}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
onRangeChange={setRange}
/>
</TabsContent>
<TabsContent value="providers" className="mt-0">
<ProviderStatsTable
range={range}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
<TabsContent value="models" className="mt-0">
<ModelStatsTable
range={range}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
</motion.div>
</Tabs>
</div>
<Accordion type="multiple" defaultValue={[]} className="w-full space-y-4">
<AccordionItem
value="pricing"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Coins className="h-5 w-5 text-yellow-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.pricing.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.pricing.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<PricingConfigPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="maintenance"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<DatabaseBackup className="h-5 w-5 text-orange-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("usage.rebuildCodex.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("usage.rebuildCodex.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<div className="flex items-center justify-between gap-4 rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<p className="text-sm text-muted-foreground">
{t("usage.rebuildCodex.warning")}
</p>
<Button
variant="destructive"
disabled={rebuildingCodex}
onClick={() => setShowRebuildConfirm(true)}
className="shrink-0"
>
{rebuildingCodex ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<DatabaseBackup className="mr-2 h-4 w-4" />
)}
{t("usage.rebuildCodex.action")}
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<ConfirmDialog
isOpen={showRebuildConfirm}
title={t("usage.rebuildCodex.confirmTitle")}
message={t("usage.rebuildCodex.confirmMessage")}
confirmText={t("usage.rebuildCodex.confirmAction")}
variant="destructive"
onConfirm={() => void rebuildCodexUsage()}
onCancel={() => setShowRebuildConfirm(false)}
/>
</motion.div>
);
}