mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
280 lines
9.9 KiB
TypeScript
280 lines
9.9 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { UsageSummaryCards } from "./UsageSummaryCards";
|
|
import { UsageTrendChart } from "./UsageTrendChart";
|
|
import { RequestLogTable } from "./RequestLogTable";
|
|
import { ProviderStatsTable } from "./ProviderStatsTable";
|
|
import { ModelStatsTable } from "./ModelStatsTable";
|
|
import type {
|
|
AppTypeFilter,
|
|
UsageRangePreset,
|
|
UsageRangeSelection,
|
|
} from "@/types/usage";
|
|
import { useUsageSummary } from "@/lib/query/usage";
|
|
import { motion } from "framer-motion";
|
|
import {
|
|
BarChart3,
|
|
ListFilter,
|
|
Activity,
|
|
RefreshCw,
|
|
Coins,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { usageKeys } from "@/lib/query/usage";
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
} from "@/components/ui/accordion";
|
|
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
|
import { cn } from "@/lib/utils";
|
|
import { fmtUsd, getLocaleFromLanguage, parseFiniteNumber } from "./format";
|
|
import { resolveUsageRange } from "@/lib/usageRange";
|
|
import { UsageDateRangePicker } from "./UsageDateRangePicker";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
const APP_FILTER_OPTIONS: AppTypeFilter[] = [
|
|
"all",
|
|
"claude",
|
|
"codex",
|
|
"gemini",
|
|
];
|
|
|
|
const RANGE_PRESETS: UsageRangePreset[] = ["today", "1d", "7d", "14d", "30d"];
|
|
|
|
function getPresetLabel(
|
|
preset: UsageRangePreset,
|
|
t: (key: string, options?: { defaultValue?: string }) => string,
|
|
): string {
|
|
switch (preset) {
|
|
case "today":
|
|
return t("usage.presetToday", { defaultValue: "当天" });
|
|
case "1d":
|
|
return t("usage.preset1d", { defaultValue: "1d" });
|
|
case "7d":
|
|
return t("usage.preset7d", { defaultValue: "7d" });
|
|
case "14d":
|
|
return t("usage.preset14d", { defaultValue: "14d" });
|
|
case "30d":
|
|
return t("usage.preset30d", { defaultValue: "30d" });
|
|
case "custom":
|
|
return t("usage.customRange", { defaultValue: "日历筛选" });
|
|
}
|
|
}
|
|
|
|
export function UsageDashboard() {
|
|
const { t, i18n } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const [range, setRange] = useState<UsageRangeSelection>({ preset: "today" });
|
|
const [appType, setAppType] = useState<AppTypeFilter>("all");
|
|
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
|
|
|
|
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
|
|
const changeRefreshInterval = () => {
|
|
const currentIndex = refreshIntervalOptionsMs.indexOf(
|
|
refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number],
|
|
);
|
|
const safeIndex = currentIndex >= 0 ? currentIndex : 3;
|
|
const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length;
|
|
const next = refreshIntervalOptionsMs[nextIndex];
|
|
setRefreshIntervalMs(next);
|
|
queryClient.invalidateQueries({ queryKey: usageKeys.all });
|
|
};
|
|
|
|
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 getPresetLabel(range.preset, t);
|
|
}
|
|
|
|
return `${new Date(resolvedRange.startDate * 1000).toLocaleString(locale)} - ${new Date(
|
|
resolvedRange.endDate * 1000,
|
|
).toLocaleString(locale)}`;
|
|
}, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]);
|
|
|
|
const { data: summaryData } = useUsageSummary(range, appType, {
|
|
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
|
});
|
|
|
|
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 gap-4">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div className="flex flex-col gap-1">
|
|
<h2 className="text-2xl font-bold">{t("usage.title")}</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("usage.subtitle")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-border/50 bg-card/40 backdrop-blur-sm p-4 space-y-4">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{APP_FILTER_OPTIONS.map((type) => (
|
|
<button
|
|
key={type}
|
|
type="button"
|
|
onClick={() => setAppType(type)}
|
|
className={cn(
|
|
"px-4 py-1.5 rounded-lg text-sm font-medium transition-all",
|
|
appType === type
|
|
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
|
: "text-muted-foreground hover:text-primary hover:bg-muted/50 border border-transparent",
|
|
)}
|
|
>
|
|
{t(`usage.appFilter.${type}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-9 px-2 text-xs text-muted-foreground"
|
|
title={t("common.refresh", "刷新")}
|
|
onClick={changeRefreshInterval}
|
|
>
|
|
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
|
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
|
|
</Button>
|
|
|
|
{RANGE_PRESETS.map((preset) => (
|
|
<Button
|
|
key={preset}
|
|
type="button"
|
|
size="sm"
|
|
variant={range.preset === preset ? "default" : "outline"}
|
|
onClick={() => setRange({ preset })}
|
|
>
|
|
{getPresetLabel(preset, t)}
|
|
</Button>
|
|
))}
|
|
|
|
<UsageDateRangePicker
|
|
selection={range}
|
|
triggerLabel={rangeLabel}
|
|
onApply={(nextRange) => setRange(nextRange)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
|
<span className="font-medium text-foreground">{rangeLabel}</span>
|
|
<span className="text-border">|</span>
|
|
<span>
|
|
{(summaryData?.totalRequests ?? 0).toLocaleString()}{" "}
|
|
{t("usage.requestsLabel")}
|
|
</span>
|
|
<span className="text-border">|</span>
|
|
<span>
|
|
{fmtUsd(parseFiniteNumber(summaryData?.totalCost) ?? 0, 4)}{" "}
|
|
{t("usage.costLabel")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UsageSummaryCards
|
|
range={range}
|
|
appType={appType}
|
|
refreshIntervalMs={refreshIntervalMs}
|
|
/>
|
|
|
|
<UsageTrendChart
|
|
range={range}
|
|
rangeLabel={rangeLabel}
|
|
appType={appType}
|
|
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}
|
|
refreshIntervalMs={refreshIntervalMs}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="providers" className="mt-0">
|
|
<ProviderStatsTable
|
|
range={range}
|
|
appType={appType}
|
|
refreshIntervalMs={refreshIntervalMs}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="models" className="mt-0">
|
|
<ModelStatsTable
|
|
range={range}
|
|
appType={appType}
|
|
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>
|
|
</Accordion>
|
|
</motion.div>
|
|
);
|
|
}
|