mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
9171ad752c
* add glm-5.2 pricing * feat(usage): add live end time option for custom date range Add a "End time follows current time" checkbox in the custom date range picker. When enabled, the end time becomes read-only and automatically tracks the current moment, so usage data always reflects up-to-the-second consumption from the chosen start time. This is especially useful under the Coding Plan 5-hour quota window — users can set the start time to when their 5h window began and keep the end time live to monitor real-time token consumption. * style(usage): fix prettier formatting for UsageDashboard * fix(usage): include liveEndTime in React Query cache keys Without liveEndTime in the query key, a live custom range and a fixed custom range with the same stored endpoints share the same cache entry. After the live range refreshes (endDate = now), switching to the fixed range shows stale data fetched through "now" instead of the original end time.
398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
import { 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,
|
||
} 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";
|
||
|
||
const APP_FILTER_OPTIONS: AppTypeFilter[] = ["all", ...KNOWN_APP_TYPES];
|
||
|
||
// 0 表示关闭自动刷新(refetchInterval=false)
|
||
const REFRESH_INTERVAL_OPTIONS_MS = [0, 5000, 10000, 30000, 60000] as const;
|
||
|
||
// 与 AppSwitcher 的 appIconName 保持一致(codex 复用 openai 图标)
|
||
const APP_FILTER_ICON: Record<AppType, string> = {
|
||
claude: "claude",
|
||
codex: "openai",
|
||
gemini: "gemini",
|
||
opencode: "opencode",
|
||
};
|
||
|
||
// 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);
|
||
|
||
export function UsageDashboard() {
|
||
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(30000);
|
||
|
||
// 切应用时清掉下游筛选,避免留下一个在新范围内查无数据的"幽灵"组合;
|
||
// 切 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 = (next: number) => {
|
||
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 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>
|
||
</Accordion>
|
||
</motion.div>
|
||
);
|
||
}
|