mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-25 07:20:41 +08:00
feat: add per-app usage filtering (Claude/Codex/Gemini)
Add dashboard-level app type filter to usage statistics, replacing the DataSourceBar with a more useful segmented control. All components (summary cards, trend chart, provider stats, model stats, request logs) now respond to the selected app filter. Backend: add optional app_type parameter to get_usage_summary, get_daily_trends, get_provider_stats, and get_model_stats queries. Frontend: new AppTypeFilter type, updated query keys with appType dimension for proper cache separation, and RequestLogTable local filter auto-locks when dashboard filter is active.
This commit is contained in:
@@ -11,12 +11,16 @@ import { useModelStats } from "@/lib/query/usage";
|
||||
import { fmtUsd } from "./format";
|
||||
|
||||
interface ModelStatsTableProps {
|
||||
appType?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) {
|
||||
export function ModelStatsTable({
|
||||
appType,
|
||||
refreshIntervalMs,
|
||||
}: ModelStatsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: stats, isLoading } = useModelStats({
|
||||
const { data: stats, isLoading } = useModelStats(appType, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,14 +11,16 @@ import { useProviderStats } from "@/lib/query/usage";
|
||||
import { fmtUsd } from "./format";
|
||||
|
||||
interface ProviderStatsTableProps {
|
||||
appType?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function ProviderStatsTable({
|
||||
appType,
|
||||
refreshIntervalMs,
|
||||
}: ProviderStatsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: stats, isLoading } = useProviderStats({
|
||||
const { data: stats, isLoading } = useProviderStats(appType, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "./format";
|
||||
|
||||
interface RequestLogTableProps {
|
||||
appType?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
@@ -37,7 +38,10 @@ const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
|
||||
|
||||
type TimeMode = "rolling" | "fixed";
|
||||
|
||||
export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
|
||||
export function RequestLogTable({
|
||||
appType: dashboardAppType,
|
||||
refreshIntervalMs,
|
||||
}: RequestLogTableProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -56,8 +60,14 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
|
||||
const pageSize = 20;
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
// When dashboard-level app filter is active (not "all"), override the local appType filter
|
||||
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
|
||||
const effectiveFilters: LogFilters = dashboardAppTypeActive
|
||||
? { ...appliedFilters, appType: dashboardAppType }
|
||||
: appliedFilters;
|
||||
|
||||
const { data: result, isLoading } = useRequestLogs({
|
||||
filters: appliedFilters,
|
||||
filters: effectiveFilters,
|
||||
timeMode: appliedTimeMode,
|
||||
rollingWindowSeconds: ONE_DAY_SECONDS,
|
||||
page,
|
||||
@@ -174,13 +184,18 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
|
||||
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
value={draftFilters.appType || "all"}
|
||||
value={
|
||||
dashboardAppTypeActive
|
||||
? dashboardAppType
|
||||
: draftFilters.appType || "all"
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
appType: v === "all" ? undefined : v,
|
||||
})
|
||||
}
|
||||
disabled={!!dashboardAppTypeActive}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] bg-background">
|
||||
<SelectValue placeholder={t("usage.appType")} />
|
||||
|
||||
@@ -6,8 +6,8 @@ import { UsageTrendChart } from "./UsageTrendChart";
|
||||
import { RequestLogTable } from "./RequestLogTable";
|
||||
import { ProviderStatsTable } from "./ProviderStatsTable";
|
||||
import { ModelStatsTable } from "./ModelStatsTable";
|
||||
import { DataSourceBar } from "./DataSourceBar";
|
||||
import type { TimeRange } from "@/types/usage";
|
||||
import type { AppTypeFilter, TimeRange } from "@/types/usage";
|
||||
import { useUsageSummary } from "@/lib/query/usage";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
BarChart3,
|
||||
@@ -26,11 +26,21 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fmtUsd, parseFiniteNumber } from "./format";
|
||||
|
||||
const APP_FILTER_OPTIONS: AppTypeFilter[] = [
|
||||
"all",
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
];
|
||||
|
||||
export function UsageDashboard() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||
const [appType, setAppType] = useState<AppTypeFilter>("all");
|
||||
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
|
||||
|
||||
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
|
||||
@@ -47,6 +57,11 @@ export function UsageDashboard() {
|
||||
|
||||
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
|
||||
|
||||
// Summary data for the app filter bar
|
||||
const { data: summaryData } = useUsageSummary(days, appType, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -101,11 +116,49 @@ export function UsageDashboard() {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DataSourceBar refreshIntervalMs={refreshIntervalMs} />
|
||||
{/* App type filter bar (replaces DataSourceBar) */}
|
||||
<div className="rounded-xl border border-border/50 bg-card/40 backdrop-blur-sm p-4 space-y-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="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<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>
|
||||
|
||||
<UsageSummaryCards days={days} refreshIntervalMs={refreshIntervalMs} />
|
||||
<UsageSummaryCards
|
||||
days={days}
|
||||
appType={appType}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
|
||||
<UsageTrendChart days={days} refreshIntervalMs={refreshIntervalMs} />
|
||||
<UsageTrendChart
|
||||
days={days}
|
||||
appType={appType}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="logs" className="w-full">
|
||||
@@ -132,15 +185,24 @@ export function UsageDashboard() {
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<TabsContent value="logs" className="mt-0">
|
||||
<RequestLogTable refreshIntervalMs={refreshIntervalMs} />
|
||||
<RequestLogTable
|
||||
appType={appType}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="providers" className="mt-0">
|
||||
<ProviderStatsTable refreshIntervalMs={refreshIntervalMs} />
|
||||
<ProviderStatsTable
|
||||
appType={appType}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models" className="mt-0">
|
||||
<ModelStatsTable refreshIntervalMs={refreshIntervalMs} />
|
||||
<ModelStatsTable
|
||||
appType={appType}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
</Tabs>
|
||||
|
||||
@@ -8,16 +8,18 @@ import { fmtUsd, parseFiniteNumber } from "./format";
|
||||
|
||||
interface UsageSummaryCardsProps {
|
||||
days: number;
|
||||
appType?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function UsageSummaryCards({
|
||||
days,
|
||||
appType,
|
||||
refreshIntervalMs,
|
||||
}: UsageSummaryCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: summary, isLoading } = useUsageSummary(days, {
|
||||
const { data: summary, isLoading } = useUsageSummary(days, appType, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,15 +20,17 @@ import {
|
||||
|
||||
interface UsageTrendChartProps {
|
||||
days: number;
|
||||
appType?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function UsageTrendChart({
|
||||
days,
|
||||
appType,
|
||||
refreshIntervalMs,
|
||||
}: UsageTrendChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { data: trends, isLoading } = useUsageTrends(days, {
|
||||
const { data: trends, isLoading } = useUsageTrends(days, appType, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1019,6 +1019,14 @@
|
||||
"stream": "Stream",
|
||||
"nonStream": "Non-stream",
|
||||
"source": "Source",
|
||||
"requestsLabel": "requests",
|
||||
"costLabel": "total cost",
|
||||
"appFilter": {
|
||||
"all": "All",
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
},
|
||||
"dataSources": "Data Sources",
|
||||
"dataSource": {
|
||||
"proxy": "Proxy",
|
||||
|
||||
@@ -1019,6 +1019,14 @@
|
||||
"stream": "ストリーム",
|
||||
"nonStream": "非ストリーム",
|
||||
"source": "ソース",
|
||||
"requestsLabel": "リクエスト",
|
||||
"costLabel": "合計コスト",
|
||||
"appFilter": {
|
||||
"all": "すべて",
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
},
|
||||
"dataSources": "データソース",
|
||||
"dataSource": {
|
||||
"proxy": "プロキシ",
|
||||
|
||||
@@ -1019,6 +1019,14 @@
|
||||
"stream": "流",
|
||||
"nonStream": "非流",
|
||||
"source": "来源",
|
||||
"requestsLabel": "次请求",
|
||||
"costLabel": "总成本",
|
||||
"appFilter": {
|
||||
"all": "全部",
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
},
|
||||
"dataSources": "数据来源",
|
||||
"dataSource": {
|
||||
"proxy": "代理",
|
||||
|
||||
@@ -50,23 +50,25 @@ export const usageApi = {
|
||||
getUsageSummary: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
appType?: string,
|
||||
): Promise<UsageSummary> => {
|
||||
return invoke("get_usage_summary", { startDate, endDate });
|
||||
return invoke("get_usage_summary", { startDate, endDate, appType });
|
||||
},
|
||||
|
||||
getUsageTrends: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
appType?: string,
|
||||
): Promise<DailyStats[]> => {
|
||||
return invoke("get_usage_trends", { startDate, endDate });
|
||||
return invoke("get_usage_trends", { startDate, endDate, appType });
|
||||
},
|
||||
|
||||
getProviderStats: async (): Promise<ProviderStats[]> => {
|
||||
return invoke("get_provider_stats");
|
||||
getProviderStats: async (appType?: string): Promise<ProviderStats[]> => {
|
||||
return invoke("get_provider_stats", { appType });
|
||||
},
|
||||
|
||||
getModelStats: async (): Promise<ModelStats[]> => {
|
||||
return invoke("get_model_stats");
|
||||
getModelStats: async (appType?: string): Promise<ModelStats[]> => {
|
||||
return invoke("get_model_stats", { appType });
|
||||
},
|
||||
|
||||
getRequestLogs: async (
|
||||
|
||||
+47
-28
@@ -34,10 +34,14 @@ type RequestLogsKey = {
|
||||
// Query keys
|
||||
export const usageKeys = {
|
||||
all: ["usage"] as const,
|
||||
summary: (days: number) => [...usageKeys.all, "summary", days] as const,
|
||||
trends: (days: number) => [...usageKeys.all, "trends", days] as const,
|
||||
providerStats: () => [...usageKeys.all, "provider-stats"] as const,
|
||||
modelStats: () => [...usageKeys.all, "model-stats"] as const,
|
||||
summary: (days: number, appType?: string) =>
|
||||
[...usageKeys.all, "summary", days, appType ?? "all"] as const,
|
||||
trends: (days: number, appType?: string) =>
|
||||
[...usageKeys.all, "trends", days, appType ?? "all"] as const,
|
||||
providerStats: (appType?: string) =>
|
||||
[...usageKeys.all, "provider-stats", appType ?? "all"] as const,
|
||||
modelStats: (appType?: string) =>
|
||||
[...usageKeys.all, "model-stats", appType ?? "all"] as const,
|
||||
logs: (key: RequestLogsKey, page: number, pageSize: number) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -67,44 +71,59 @@ const getWindow = (days: number) => {
|
||||
};
|
||||
|
||||
// Hooks
|
||||
export function useUsageSummary(days: number, options?: UsageQueryOptions) {
|
||||
export function useUsageSummary(
|
||||
days: number,
|
||||
appType?: string,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
return useQuery({
|
||||
queryKey: usageKeys.summary(days),
|
||||
queryKey: usageKeys.summary(days, appType),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = getWindow(days);
|
||||
return usageApi.getUsageSummary(startDate, endDate);
|
||||
return usageApi.getUsageSummary(startDate, endDate, effectiveAppType);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, // 后台不刷新
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsageTrends(days: number, options?: UsageQueryOptions) {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.trends(days),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = getWindow(days);
|
||||
return usageApi.getUsageTrends(startDate, endDate);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProviderStats(options?: UsageQueryOptions) {
|
||||
export function useUsageTrends(
|
||||
days: number,
|
||||
appType?: string,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
return useQuery({
|
||||
queryKey: usageKeys.providerStats(),
|
||||
queryFn: usageApi.getProviderStats,
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
|
||||
queryKey: usageKeys.trends(days, appType),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = getWindow(days);
|
||||
return usageApi.getUsageTrends(startDate, endDate, effectiveAppType);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useModelStats(options?: UsageQueryOptions) {
|
||||
export function useProviderStats(
|
||||
appType?: string,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
return useQuery({
|
||||
queryKey: usageKeys.modelStats(),
|
||||
queryFn: usageApi.getModelStats,
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
|
||||
queryKey: usageKeys.providerStats(appType),
|
||||
queryFn: () => usageApi.getProviderStats(effectiveAppType),
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useModelStats(appType?: string, options?: UsageQueryOptions) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
return useQuery({
|
||||
queryKey: usageKeys.modelStats(appType),
|
||||
queryFn: () => usageApi.getModelStats(effectiveAppType),
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@ export interface ProviderLimitStatus {
|
||||
|
||||
export type TimeRange = "1d" | "7d" | "30d";
|
||||
|
||||
export type AppTypeFilter = "all" | "claude" | "codex" | "gemini";
|
||||
|
||||
export interface StatsFilters {
|
||||
timeRange: TimeRange;
|
||||
providerId?: string;
|
||||
|
||||
Reference in New Issue
Block a user