Files
CC-Switch/src/components/usage/UsageDashboard.tsx
T
Arc 9171ad752c feat(usage): add live end time option for custom date range (#4438)
* 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.
2026-06-23 23:04:00 +08:00

398 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}