mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(usage): persist dashboard refresh interval (#5057)
This commit is contained in:
@@ -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<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usage_dashboard_refresh_interval_ms: Option<u32>,
|
||||
/// User has confirmed the stream check first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stream_check_confirmed: Option<bool>,
|
||||
@@ -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,
|
||||
|
||||
@@ -507,7 +507,12 @@ export function SettingsPage({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="usage" className="mt-0">
|
||||
<UsageDashboard />
|
||||
<UsageDashboard
|
||||
refreshIntervalMs={settings?.usageDashboardRefreshIntervalMs}
|
||||
onRefreshIntervalChange={(usageDashboardRefreshIntervalMs) =>
|
||||
handleAutoSave({ usageDashboardRefreshIntervalMs })
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<AppType, string> = {
|
||||
@@ -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> | boolean | void;
|
||||
}
|
||||
|
||||
export function UsageDashboard({
|
||||
refreshIntervalMs: savedRefreshIntervalMs,
|
||||
onRefreshIntervalChange,
|
||||
}: UsageDashboardProps = {}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [range, setRange] = useState<UsageRangeSelection>({ preset: "today" });
|
||||
@@ -73,7 +90,13 @@ export function UsageDashboard() {
|
||||
undefined,
|
||||
);
|
||||
const [model, setModel] = useState<string | undefined>(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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => <div {...props}>{children}</div>,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useUsageEventBridge", () => ({
|
||||
useUsageEventBridge: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/query/usage", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/query/usage")>(
|
||||
"@/lib/query/usage",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useProviderStats: (...args: unknown[]) => useProviderStatsMock(...args),
|
||||
useModelStats: (...args: unknown[]) => useModelStatsMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/usage/UsageHero", () => ({
|
||||
UsageHero: () => <div data-testid="usage-hero" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/usage/UsageTrendChart", () => ({
|
||||
UsageTrendChart: () => <div data-testid="usage-trend" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/usage/RequestLogTable", () => ({
|
||||
RequestLogTable: () => <div data-testid="request-log-table" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/usage/ProviderStatsTable", () => ({
|
||||
ProviderStatsTable: () => <div data-testid="provider-stats-table" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/usage/ModelStatsTable", () => ({
|
||||
ModelStatsTable: () => <div data-testid="model-stats-table" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/usage/PricingConfigPanel", () => ({
|
||||
PricingConfigPanel: () => <div data-testid="pricing-config-panel" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/usage/UsageDateRangePicker", () => ({
|
||||
UsageDateRangePicker: () => <button type="button">date-range</button>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: ({ value, onValueChange, children }: any) => (
|
||||
<div data-testid={`select-${value}`}>
|
||||
{children}
|
||||
<button type="button" onClick={() => onValueChange?.("5000")}>
|
||||
choose-5000
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
SelectTrigger: ({ children, ...props }: any) => (
|
||||
<button type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: any) => <div>{children}</div>,
|
||||
SelectItem: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
}));
|
||||
|
||||
const renderDashboard = (props: ComponentProps<typeof UsageDashboard> = {}) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<UsageDashboard {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user