From 98ccde0050f33a1bc8b16b96287a0b6f582c5d12 Mon Sep 17 00:00:00 2001 From: makoMakoGo <48956204+makoMakoGo@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:02:23 +0800 Subject: [PATCH] fix(usage): persist dashboard refresh interval (#5057) --- src-tauri/src/settings.rs | 3 + src/components/settings/SettingsPage.tsx | 7 +- src/components/usage/UsageDashboard.tsx | 49 ++++++- src/lib/schemas/settings.ts | 1 + src/types.ts | 1 + tests/components/UsageDashboard.test.tsx | 155 +++++++++++++++++++++++ 6 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 tests/components/UsageDashboard.test.tsx diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ffa00909f..8bdd24cca 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -366,6 +366,8 @@ pub struct AppSettings { /// User has confirmed the usage query first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub usage_confirmed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage_dashboard_refresh_interval_ms: Option, /// User has confirmed the stream check first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub stream_check_confirmed: Option, @@ -501,6 +503,7 @@ impl Default for AppSettings { enable_local_proxy: false, proxy_confirmed: None, usage_confirmed: None, + usage_dashboard_refresh_interval_ms: None, stream_check_confirmed: None, enable_failover_toggle: false, preserve_codex_official_auth_on_switch: false, diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 49cf0dc79..3ab2dd712 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -507,7 +507,12 @@ export function SettingsPage({ - + + handleAutoSave({ usageDashboardRefreshIntervalMs }) + } + /> diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index 4eb2485d4..a03fa833c 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { UsageHero } from "./UsageHero"; import { UsageTrendChart } from "./UsageTrendChart"; @@ -46,8 +46,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const APP_FILTER_OPTIONS: AppTypeFilter[] = ["all", ...KNOWN_APP_TYPES]; -// 0 表示关闭自动刷新(refetchInterval=false) +const DEFAULT_REFRESH_INTERVAL_MS = 30000; const REFRESH_INTERVAL_OPTIONS_MS = [0, 5000, 10000, 30000, 60000] as const; +type RefreshIntervalOption = (typeof REFRESH_INTERVAL_OPTIONS_MS)[number]; + +const isRefreshIntervalOption = ( + value: number | undefined, +): value is RefreshIntervalOption => + REFRESH_INTERVAL_OPTIONS_MS.includes(value as RefreshIntervalOption); + +const normalizeRefreshInterval = (value: number | undefined) => + isRefreshIntervalOption(value) ? value : DEFAULT_REFRESH_INTERVAL_MS; // 与 AppSwitcher 的 appIconName 保持一致(codex 复用 openai 图标) const APP_FILTER_ICON: Record = { @@ -64,7 +73,15 @@ const encodeOptionValue = (name: string) => `${DYNAMIC_OPTION_PREFIX}${name}`; const decodeOptionValue = (value: string) => value === "all" ? undefined : value.slice(DYNAMIC_OPTION_PREFIX.length); -export function UsageDashboard() { +interface UsageDashboardProps { + refreshIntervalMs?: number; + onRefreshIntervalChange?: (next: number) => Promise | boolean | void; +} + +export function UsageDashboard({ + refreshIntervalMs: savedRefreshIntervalMs, + onRefreshIntervalChange, +}: UsageDashboardProps = {}) { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); const [range, setRange] = useState({ preset: "today" }); @@ -73,7 +90,13 @@ export function UsageDashboard() { undefined, ); const [model, setModel] = useState(undefined); - const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000); + const [refreshIntervalMs, setRefreshIntervalMs] = useState(() => + normalizeRefreshInterval(savedRefreshIntervalMs), + ); + + useEffect(() => { + setRefreshIntervalMs(normalizeRefreshInterval(savedRefreshIntervalMs)); + }, [savedRefreshIntervalMs]); // 切应用时清掉下游筛选,避免留下一个在新范围内查无数据的"幽灵"组合; // 切 Provider 同理清掉模型(模型选项随 Provider 级联)。 @@ -95,9 +118,23 @@ export function UsageDashboard() { // usage 查询,实现实时刷新(仅在 Dashboard 挂载时生效,离开页面自动取消监听) useUsageEventBridge(); - const changeRefreshInterval = (next: number) => { - setRefreshIntervalMs(next); + const changeRefreshInterval = async (next: number) => { + const normalized = normalizeRefreshInterval(next); + const previous = refreshIntervalMs; + setRefreshIntervalMs(normalized); queryClient.invalidateQueries({ queryKey: usageKeys.all }); + try { + const saved = await onRefreshIntervalChange?.(normalized); + if (saved === false) { + setRefreshIntervalMs(previous); + } + } catch (error) { + console.error( + "[UsageDashboard] Failed to persist refresh interval", + error, + ); + setRefreshIntervalMs(previous); + } }; const language = i18n.resolvedLanguage || i18n.language || "en"; diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index dbdff5e1e..2d2780d8e 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -15,6 +15,7 @@ export const settingsSchema = z.object({ skipClaudeOnboarding: z.boolean().optional(), launchOnStartup: z.boolean().optional(), enableLocalProxy: z.boolean().optional(), + usageDashboardRefreshIntervalMs: z.number().optional(), preserveCodexOfficialAuthOnSwitch: z.boolean().optional(), unifyCodexSessionHistory: z.boolean().optional(), language: z.enum(["en", "zh", "zh-TW", "ja"]).optional(), diff --git a/src/types.ts b/src/types.ts index 6dcb66af6..d8768a092 100644 --- a/src/types.ts +++ b/src/types.ts @@ -361,6 +361,7 @@ export interface Settings { proxyConfirmed?: boolean; // User has confirmed the usage query first-run notice usageConfirmed?: boolean; + usageDashboardRefreshIntervalMs?: number; // User has confirmed the stream check first-run notice streamCheckConfirmed?: boolean; // Whether to show the failover toggle independently on the main page diff --git a/tests/components/UsageDashboard.test.tsx b/tests/components/UsageDashboard.test.tsx new file mode 100644 index 000000000..644189aa1 --- /dev/null +++ b/tests/components/UsageDashboard.test.tsx @@ -0,0 +1,155 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UsageDashboard } from "@/components/usage/UsageDashboard"; + +const useProviderStatsMock = vi.hoisted(() => vi.fn()); +const useModelStatsMock = vi.hoisted(() => vi.fn()); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + i18n: { + resolvedLanguage: "en", + language: "en", + }, + }), +})); + +vi.mock("framer-motion", () => ({ + motion: { + div: ({ children, ...props }: any) =>
{children}
, + }, +})); + +vi.mock("@/hooks/useUsageEventBridge", () => ({ + useUsageEventBridge: () => {}, +})); + +vi.mock("@/lib/query/usage", async () => { + const actual = + await vi.importActual( + "@/lib/query/usage", + ); + return { + ...actual, + useProviderStats: (...args: unknown[]) => useProviderStatsMock(...args), + useModelStats: (...args: unknown[]) => useModelStatsMock(...args), + }; +}); + +vi.mock("@/components/usage/UsageHero", () => ({ + UsageHero: () =>
, +})); + +vi.mock("@/components/usage/UsageTrendChart", () => ({ + UsageTrendChart: () =>
, +})); + +vi.mock("@/components/usage/RequestLogTable", () => ({ + RequestLogTable: () =>
, +})); + +vi.mock("@/components/usage/ProviderStatsTable", () => ({ + ProviderStatsTable: () =>
, +})); + +vi.mock("@/components/usage/ModelStatsTable", () => ({ + ModelStatsTable: () =>
, +})); + +vi.mock("@/components/usage/PricingConfigPanel", () => ({ + PricingConfigPanel: () =>
, +})); + +vi.mock("@/components/usage/UsageDateRangePicker", () => ({ + UsageDateRangePicker: () => , +})); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ value, onValueChange, children }: any) => ( +
+ {children} + +
+ ), + SelectTrigger: ({ children, ...props }: any) => ( + + ), + SelectValue: () => null, + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, ...props }: any) =>
{children}
, +})); + +const renderDashboard = (props: ComponentProps = {}) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return render( + + + , + ); +}; + +describe("UsageDashboard", () => { + beforeEach(() => { + useProviderStatsMock.mockReset(); + useModelStatsMock.mockReset(); + useProviderStatsMock.mockReturnValue({ data: [] }); + useModelStatsMock.mockReturnValue({ data: [] }); + }); + + it("uses the saved refresh interval when mounted", () => { + renderDashboard({ refreshIntervalMs: 5000 }); + + expect(screen.getByTestId("select-5000")).toBeInTheDocument(); + }); + + it("persists refresh interval changes", async () => { + const onRefreshIntervalChange = vi.fn().mockResolvedValue(true); + renderDashboard({ onRefreshIntervalChange }); + + fireEvent.click( + within(screen.getByTestId("select-30000")).getByRole("button", { + name: "choose-5000", + }), + ); + + await waitFor(() => + expect(onRefreshIntervalChange).toHaveBeenCalledWith(5000), + ); + expect(screen.getByTestId("select-5000")).toBeInTheDocument(); + }); + + it("rolls back optimistic interval changes when persistence fails", async () => { + const onRefreshIntervalChange = vi.fn().mockResolvedValue(false); + renderDashboard({ onRefreshIntervalChange }); + + fireEvent.click( + within(screen.getByTestId("select-30000")).getByRole("button", { + name: "choose-5000", + }), + ); + + await waitFor(() => + expect(onRefreshIntervalChange).toHaveBeenCalledWith(5000), + ); + await waitFor(() => + expect(screen.getByTestId("select-30000")).toBeInTheDocument(), + ); + }); +});