feat(usage): lift provider/model filters to dashboard-wide scope

The provider/model filters only lived inside the request-log table, so
there was no way to see "how much did app X spend on source Y" across
the whole dashboard. Promote them to the top bar next to the app
filter, applying globally to the hero summary, trend chart, request
logs, and both stats tabs.

Backend: the five stats queries (summary, summary-by-app, trends,
provider stats, model stats) accept optional provider_name/model
filters, applied to both the detail and daily-rollup branches (the
rollup PK already carries provider_id/model/pricing_model). Sources
match by exact display name via provider_name_coalesce, so session
placeholder rows like "Claude (Session)" are selectable; models match
by effective pricing model (pricing_model falling back to model), the
same grouping key the model-stats tab uses. Request-log filtering
switches from LIKE to these exact semantics.

Frontend: two truncating dropdowns list only sources/models that have
data in the current range, with the model list cascading from the
selected source. Dynamic option values are prefix-encoded so a source
literally named "all" cannot collide with the sentinel, query keys
fall back to null instead of "all", and the option queries follow the
dashboard refresh interval (otherwise their default 30s polling drags
same-key stats queries along even with refresh disabled). The
request-log filter bar keeps only the log-specific status-code select.
Labels read "sources" rather than "providers" because direct-connect
session buckets sit alongside real providers. i18n updated across
zh/en/ja/zh-TW.
This commit is contained in:
Jason
2026-06-11 16:14:20 +08:00
parent c701068f0c
commit a75f479576
15 changed files with 871 additions and 281 deletions
+11 -3
View File
@@ -14,18 +14,26 @@ import type { UsageRangeSelection } from "@/types/usage";
interface ModelStatsTableProps {
range: UsageRangeSelection;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
export function ModelStatsTable({
range,
appType,
providerName,
model,
refreshIntervalMs,
}: ModelStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useModelStats(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const { data: stats, isLoading } = useModelStats(
range,
{ appType, providerName, model },
{
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
);
if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
+11 -3
View File
@@ -14,18 +14,26 @@ import type { UsageRangeSelection } from "@/types/usage";
interface ProviderStatsTableProps {
range: UsageRangeSelection;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
export function ProviderStatsTable({
range,
appType,
providerName,
model,
refreshIntervalMs,
}: ProviderStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useProviderStats(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const { data: stats, isLoading } = useProviderStats(
range,
{ appType, providerName, model },
{
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
);
if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
+27 -127
View File
@@ -19,13 +19,12 @@ import {
} from "@/components/ui/select";
import { useRequestLogs } from "@/lib/query/usage";
import {
KNOWN_APP_TYPES,
getFreshInputTokens,
isUnpricedUsage,
type LogFilters,
type UsageRangeSelection,
} from "@/types/usage";
import { ChevronLeft, ChevronRight, Search, X } from "lucide-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import {
fmtInt,
@@ -38,6 +37,8 @@ interface RequestLogTableProps {
range: UsageRangeSelection;
rangeLabel: string;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
onRangeChange?: (range: UsageRangeSelection) => void;
}
@@ -46,21 +47,29 @@ export function RequestLogTable({
range,
rangeLabel,
appType: dashboardAppType,
providerName,
model,
refreshIntervalMs,
onRangeChange,
}: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
// 应用/Provider/模型筛选已上移到 Dashboard 顶栏(全局生效);
// 这里只保留日志特有的状态码筛选。
const [statusCode, setStatusCode] = useState<number | undefined>(undefined);
const [page, setPage] = useState(0);
const [pageInput, setPageInput] = useState("");
const pageSize = 20;
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
const effectiveFilters: LogFilters = dashboardAppTypeActive
? { ...appliedFilters, appType: dashboardAppType }
: appliedFilters;
const effectiveFilters: LogFilters = {
appType:
dashboardAppType && dashboardAppType !== "all"
? dashboardAppType
: undefined,
providerName,
model,
statusCode,
};
const { data: result, isLoading } = useRequestLogs({
filters: effectiveFilters,
@@ -80,37 +89,13 @@ export function RequestLogTable({
setPage(0);
}, [
dashboardAppType,
providerName,
model,
range.customEndDate,
range.customStartDate,
range.preset,
]);
const handleSearch = () => {
setAppliedFilters(draftFilters);
setPage(0);
};
const handleReset = () => {
setDraftFilters({});
setAppliedFilters({});
setPage(0);
};
const applySelectFilter = <K extends keyof LogFilters>(
key: K,
value: LogFilters[K],
) => {
setDraftFilters((prev) => ({
...prev,
[key]: value,
}));
setAppliedFilters((prev) => ({
...prev,
[key]: value,
}));
setPage(0);
};
const handleGoToPage = () => {
const trimmed = pageInput.trim();
if (!/^\d+$/.test(trimmed)) return;
@@ -127,44 +112,16 @@ export function RequestLogTable({
<div className="space-y-4">
<div className="rounded-lg border bg-card/50 p-2 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-1.5">
{/* App type */}
<Select
value={
dashboardAppTypeActive
? dashboardAppType
: draftFilters.appType || "all"
}
onValueChange={(v) =>
applySelectFilter("appType", v === "all" ? undefined : v)
}
disabled={!!dashboardAppTypeActive}
>
<SelectTrigger className="h-8 w-[110px] bg-background text-xs">
<SelectValue placeholder={t("usage.appType")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
{KNOWN_APP_TYPES.map((at) => (
<SelectItem key={at} value={at}>
{t(`usage.appFilter.${at}`, { defaultValue: at })}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status code */}
<Select
value={draftFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
applySelectFilter(
"statusCode",
v === "all"
? undefined
: Number.isFinite(Number.parseInt(v, 10))
? Number.parseInt(v, 10)
: undefined,
)
}
value={statusCode?.toString() || "all"}
onValueChange={(v) => {
const parsed = Number.parseInt(v, 10);
setStatusCode(
v === "all" || !Number.isFinite(parsed) ? undefined : parsed,
);
setPage(0);
}}
>
<SelectTrigger className="h-8 w-[100px] bg-background text-xs">
<SelectValue placeholder={t("usage.statusCode")} />
@@ -179,43 +136,6 @@ export function RequestLogTable({
</SelectContent>
</Select>
{/* Provider search */}
<div className="relative min-w-[140px] flex-1">
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("usage.searchProviderPlaceholder")}
className="h-8 bg-background pl-7 text-xs"
value={draftFilters.providerName || ""}
onChange={(e) =>
setDraftFilters({
...draftFilters,
providerName: e.target.value || undefined,
})
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
</div>
{/* Model search */}
<div className="relative min-w-[120px] flex-1">
<Input
placeholder={t("usage.searchModelPlaceholder")}
className="h-8 bg-background text-xs"
value={draftFilters.model || ""}
onChange={(e) =>
setDraftFilters({
...draftFilters,
model: e.target.value || undefined,
})
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
</div>
{onRangeChange && (
<UsageDateRangePicker
selection={range}
@@ -223,26 +143,6 @@ export function RequestLogTable({
onApply={onRangeChange}
/>
)}
{/* Search & Reset (icon-only) */}
<Button
size="icon"
variant="default"
onClick={handleSearch}
className="h-8 w-8"
title={t("common.search")}
>
<Search className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="outline"
onClick={handleReset}
className="h-8 w-8"
title={t("common.reset")}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
+137 -2
View File
@@ -22,8 +22,15 @@ import {
} from "lucide-react";
import { ProviderIcon } from "@/components/ProviderIcon";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useQueryClient } from "@tanstack/react-query";
import { usageKeys } from "@/lib/query/usage";
import { usageKeys, useModelStats, useProviderStats } from "@/lib/query/usage";
import { useUsageEventBridge } from "@/hooks/useUsageEventBridge";
import {
Accordion,
@@ -48,13 +55,40 @@ const APP_FILTER_ICON: Record<AppType, string> = {
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();
@@ -84,6 +118,45 @@ export function UsageDashboard() {
).toLocaleString(locale)}`;
}, [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 }}
@@ -107,7 +180,7 @@ export function UsageDashboard() {
<button
key={type}
type="button"
onClick={() => setAppType(type)}
onClick={() => changeAppType(type)}
title={label}
aria-label={label}
className={cn(
@@ -131,6 +204,58 @@ export function UsageDashboard() {
})}
</div>
<Select
value={
providerName != null ? encodeOptionValue(providerName) : "all"
}
onValueChange={(v) => changeProviderName(decodeOptionValue(v))}
>
<SelectTrigger
className="h-9 w-[110px] bg-background text-xs [&>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-[120px] bg-background text-xs [&>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">
<Button
type="button"
@@ -156,6 +281,8 @@ export function UsageDashboard() {
<UsageHero
range={range}
appType={appType === "all" ? undefined : appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
@@ -163,6 +290,8 @@ export function UsageDashboard() {
range={range}
rangeLabel={rangeLabel}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
@@ -195,6 +324,8 @@ export function UsageDashboard() {
range={range}
rangeLabel={rangeLabel}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
onRangeChange={setRange}
/>
@@ -204,6 +335,8 @@ export function UsageDashboard() {
<ProviderStatsTable
range={range}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
@@ -212,6 +345,8 @@ export function UsageDashboard() {
<ModelStatsTable
range={range}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
+11 -3
View File
@@ -33,6 +33,8 @@ import {
interface UsageHeroProps {
range: UsageRangeSelection;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
@@ -159,14 +161,20 @@ function AppGlyph({
export function UsageHero({
range,
appType,
providerName,
model,
refreshIntervalMs,
}: UsageHeroProps) {
const { t, i18n } = useTranslation();
const lang = getResolvedLang(i18n);
const { data, isLoading } = useUsageSummaryByApp(range, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const { data, isLoading } = useUsageSummaryByApp(
range,
{ providerName, model },
{
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
);
// No client-side filtering: Hero's totals must match the Trend/Logs/Stats
// below, which all go through the backend's full set of app_types. The
+11 -3
View File
@@ -24,6 +24,8 @@ interface UsageTrendChartProps {
range: UsageRangeSelection;
rangeLabel: string;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
@@ -31,13 +33,19 @@ export function UsageTrendChart({
range,
rangeLabel,
appType,
providerName,
model,
refreshIntervalMs,
}: UsageTrendChartProps) {
const { t, i18n } = useTranslation();
const { startDate, endDate } = resolveUsageRange(range);
const { data: trends, isLoading } = useUsageTrends(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const { data: trends, isLoading } = useUsageTrends(
range,
{ appType, providerName, model },
{
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
);
if (isLoading) {
return (
+4 -3
View File
@@ -1441,10 +1441,11 @@
"modelIdPlaceholder": "e.g., claude-3-5-sonnet-20241022",
"displayNamePlaceholder": "e.g., Claude 3.5 Sonnet",
"appType": "App Type",
"allApps": "All Apps",
"statusCode": "Status Code",
"searchProviderPlaceholder": "Search provider...",
"searchModelPlaceholder": "Search model...",
"allSources": "All Sources",
"allModels": "All Models",
"filterBySource": "Filter by source",
"filterByModel": "Filter by model",
"timeRange": "Time Range",
"customRange": "Calendar Filter",
"customRangeHint": "Supports both date and time",
+4 -3
View File
@@ -1441,10 +1441,11 @@
"modelIdPlaceholder": "例: claude-3-5-sonnet-20241022",
"displayNamePlaceholder": "例: Claude 3.5 Sonnet",
"appType": "アプリ種別",
"allApps": "すべてのアプリ",
"statusCode": "ステータスコード",
"searchProviderPlaceholder": "プロバイダーを検索...",
"searchModelPlaceholder": "モデルを検索...",
"allSources": "すべてのソース",
"allModels": "すべてのモデル",
"filterBySource": "ソースで絞り込む",
"filterByModel": "モデルで絞り込む",
"timeRange": "期間",
"customRange": "カレンダーフィルター",
"customRangeHint": "日付と時刻の両方に対応",
+4 -3
View File
@@ -1413,10 +1413,11 @@
"modelIdPlaceholder": "例如: claude-3-5-sonnet-20241022",
"displayNamePlaceholder": "例如: Claude 3.5 Sonnet",
"appType": "應用程式類型",
"allApps": "全部應用程式",
"statusCode": "狀態碼",
"searchProviderPlaceholder": "搜尋供應商...",
"searchModelPlaceholder": "搜尋模型...",
"allSources": "全部來源",
"allModels": "全部模型",
"filterBySource": "依來源篩選",
"filterByModel": "依模型篩選",
"timeRange": "時間範圍",
"customRange": "日曆篩選",
"customRangeHint": "支援日期與時間",
+4 -3
View File
@@ -1441,10 +1441,11 @@
"modelIdPlaceholder": "例如: claude-3-5-sonnet-20241022",
"displayNamePlaceholder": "例如: Claude 3.5 Sonnet",
"appType": "应用类型",
"allApps": "全部应用",
"statusCode": "状态码",
"searchProviderPlaceholder": "搜索供应商...",
"searchModelPlaceholder": "搜索模型...",
"allSources": "全部来源",
"allModels": "全部模型",
"filterBySource": "按来源筛选",
"filterByModel": "按模型筛选",
"timeRange": "时间范围",
"customRange": "日历筛选",
"customRangeHint": "支持日期与时间",
+44 -5
View File
@@ -52,39 +52,78 @@ export const usageApi = {
startDate?: number,
endDate?: number,
appType?: string,
providerName?: string,
model?: string,
): Promise<UsageSummary> => {
return invoke("get_usage_summary", { startDate, endDate, appType });
return invoke("get_usage_summary", {
startDate,
endDate,
appType,
providerName,
model,
});
},
getUsageSummaryByApp: async (
startDate?: number,
endDate?: number,
providerName?: string,
model?: string,
): Promise<UsageSummaryByApp[]> => {
return invoke("get_usage_summary_by_app", { startDate, endDate });
return invoke("get_usage_summary_by_app", {
startDate,
endDate,
providerName,
model,
});
},
getUsageTrends: async (
startDate?: number,
endDate?: number,
appType?: string,
providerName?: string,
model?: string,
): Promise<DailyStats[]> => {
return invoke("get_usage_trends", { startDate, endDate, appType });
return invoke("get_usage_trends", {
startDate,
endDate,
appType,
providerName,
model,
});
},
getProviderStats: async (
startDate?: number,
endDate?: number,
appType?: string,
providerName?: string,
model?: string,
): Promise<ProviderStats[]> => {
return invoke("get_provider_stats", { startDate, endDate, appType });
return invoke("get_provider_stats", {
startDate,
endDate,
appType,
providerName,
model,
});
},
getModelStats: async (
startDate?: number,
endDate?: number,
appType?: string,
providerName?: string,
model?: string,
): Promise<ModelStats[]> => {
return invoke("get_model_stats", { startDate, endDate, appType });
return invoke("get_model_stats", {
startDate,
endDate,
appType,
providerName,
model,
});
},
getRequestLogs: async (
+81 -26
View File
@@ -1,7 +1,11 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { usageApi } from "@/lib/api/usage";
import { resolveUsageRange } from "@/lib/usageRange";
import type { LogFilters, UsageRangeSelection } from "@/types/usage";
import type {
LogFilters,
UsageRangeSelection,
UsageScopeFilters,
} from "@/types/usage";
const DEFAULT_REFETCH_INTERVAL_MS = 30000;
@@ -35,7 +39,7 @@ export const usageKeys = {
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
appType?: string,
filters?: UsageScopeFilters,
) =>
[
...usageKeys.all,
@@ -43,12 +47,15 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
appType ?? "all",
filters?.appType ?? null,
filters?.providerName ?? null,
filters?.model ?? null,
] as const,
summaryByApp: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
filters?: UsageScopeFilters,
) =>
[
...usageKeys.all,
@@ -56,12 +63,14 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
filters?.providerName ?? null,
filters?.model ?? null,
] as const,
trends: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
appType?: string,
filters?: UsageScopeFilters,
) =>
[
...usageKeys.all,
@@ -69,13 +78,15 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
appType ?? "all",
filters?.appType ?? null,
filters?.providerName ?? null,
filters?.model ?? null,
] as const,
providerStats: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
appType?: string,
filters?: UsageScopeFilters,
) =>
[
...usageKeys.all,
@@ -83,13 +94,15 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
appType ?? "all",
filters?.appType ?? null,
filters?.providerName ?? null,
filters?.model ?? null,
] as const,
modelStats: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
appType?: string,
filters?: UsageScopeFilters,
) =>
[
...usageKeys.all,
@@ -97,7 +110,9 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
appType ?? "all",
filters?.appType ?? null,
filters?.providerName ?? null,
filters?.model ?? null,
] as const,
logs: (key: RequestLogsKey, page: number, pageSize: number) =>
[
@@ -122,23 +137,38 @@ export const usageKeys = {
[...usageKeys.all, providerId, appType] as const,
};
/** 把 UI 侧的 "all" 哨兵归一成 undefined(后端语义:不过滤)。 */
function normalizeScopeFilters(filters?: UsageScopeFilters): UsageScopeFilters {
return {
appType: filters?.appType === "all" ? undefined : filters?.appType,
providerName: filters?.providerName,
model: filters?.model,
};
}
// Hooks
export function useUsageSummary(
range: UsageRangeSelection,
appType?: string,
filters?: UsageScopeFilters,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
const effective = normalizeScopeFilters(filters);
return useQuery({
queryKey: usageKeys.summary(
range.preset,
range.customStartDate,
range.customEndDate,
appType,
effective,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getUsageSummary(startDate, endDate, effectiveAppType);
return usageApi.getUsageSummary(
startDate,
endDate,
effective.appType,
effective.providerName,
effective.model,
);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
@@ -147,6 +177,7 @@ export function useUsageSummary(
export function useUsageSummaryByApp(
range: UsageRangeSelection,
filters?: Pick<UsageScopeFilters, "providerName" | "model">,
options?: UsageQueryOptions,
) {
return useQuery({
@@ -154,10 +185,16 @@ export function useUsageSummaryByApp(
range.preset,
range.customStartDate,
range.customEndDate,
filters,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getUsageSummaryByApp(startDate, endDate);
return usageApi.getUsageSummaryByApp(
startDate,
endDate,
filters?.providerName,
filters?.model,
);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
@@ -166,20 +203,26 @@ export function useUsageSummaryByApp(
export function useUsageTrends(
range: UsageRangeSelection,
appType?: string,
filters?: UsageScopeFilters,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
const effective = normalizeScopeFilters(filters);
return useQuery({
queryKey: usageKeys.trends(
range.preset,
range.customStartDate,
range.customEndDate,
appType,
effective,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getUsageTrends(startDate, endDate, effectiveAppType);
return usageApi.getUsageTrends(
startDate,
endDate,
effective.appType,
effective.providerName,
effective.model,
);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
@@ -188,20 +231,26 @@ export function useUsageTrends(
export function useProviderStats(
range: UsageRangeSelection,
appType?: string,
filters?: UsageScopeFilters,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
const effective = normalizeScopeFilters(filters);
return useQuery({
queryKey: usageKeys.providerStats(
range.preset,
range.customStartDate,
range.customEndDate,
appType,
effective,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getProviderStats(startDate, endDate, effectiveAppType);
return usageApi.getProviderStats(
startDate,
endDate,
effective.appType,
effective.providerName,
effective.model,
);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
@@ -210,20 +259,26 @@ export function useProviderStats(
export function useModelStats(
range: UsageRangeSelection,
appType?: string,
filters?: UsageScopeFilters,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
const effective = normalizeScopeFilters(filters);
return useQuery({
queryKey: usageKeys.modelStats(
range.preset,
range.customStartDate,
range.customEndDate,
appType,
effective,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getModelStats(startDate, endDate, effectiveAppType);
return usageApi.getModelStats(
startDate,
endDate,
effective.appType,
effective.providerName,
effective.model,
);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
+14
View File
@@ -122,6 +122,20 @@ export interface LogFilters {
endDate?: number;
}
/**
* Dashboard 顶栏的全局筛选维度,作用于 Hero / 趋势图 / 三个统计 Tab。
*
* - `providerName` 按展示名精确匹配(与 Provider 统计列表同口径,含
* "Claude (Session)" 等会话占位名);
* - `model` 按「有效计价模型」匹配(pricing_model 优先、回落 model
* 与模型统计的分组口径一致)。
*/
export interface UsageScopeFilters {
appType?: string;
providerName?: string;
model?: string;
}
export interface ProviderLimitStatus {
providerId: string;
dailyUsage: string;