Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/usage/UsageDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -864,7 +864,7 @@ export function UsageDashboard({
compact={compact}
/>

{/* 「全部」看长期分布用热力图;24 小时到 30 天这类短范围用柱状图 */}
{/* 「全部」看长期分布用热力图;24 小时到 30 天这类短范围用趋势图 */}
{range.preset === "all" ? (
<UsageHeatmap
appType={appType}
Expand Down
74 changes: 57 additions & 17 deletions src/components/usage/UsageTrendChart.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Expand Down Expand Up @@ -50,7 +52,7 @@ export interface UsageTrendChartPoint {
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
/** 请求数(「请求」指标的柱) */
/** 请求数(「请求」指标) */
requests: number;
/** 真实消耗 Tokens = 新增输入 + 输出 + 缓存写入 + 缓存命中(和指标卡同口径) */
tokens: number;
Expand Down Expand Up @@ -165,6 +167,8 @@ export function formatUsageTrendTokenTickLabel(

const TREND_METRICS = ["requests", "tokens", "cost"] as const;
type TrendMetric = (typeof TREND_METRICS)[number];
const CHART_TYPES = ["bar", "area"] as const;
type ChartType = (typeof CHART_TYPES)[number];

const AXIS_TICK = { fill: "var(--text-3)", fontSize: 11 };

Expand All @@ -183,6 +187,11 @@ export function UsageTrendChart({
TREND_METRICS,
"tokens",
);
const [chartType, setChartType] = useRememberedChoice<ChartType>(
USAGE_CHOICE_KEYS.trendChartType,
CHART_TYPES,
"bar",
);
const { startDate, endDate } = resolveUsageRange(range);
const { data: trends, isLoading } = useUsageTrends(
range,
Expand Down Expand Up @@ -227,6 +236,14 @@ export function UsageTrendChart({
? moneyTick(value)
: formatUsageTrendTokenTickLabel(value, tokenTickFormatter);

const Chart = chartType === "bar" ? BarChart : AreaChart;
// 成本先换算成显示币种再画,Y 轴才会按这个币种挑整齐的刻度。
const dataKey =
metric === "cost"
? (point: UsageTrendChartPoint) =>
point.cost == null ? null : point.cost * rate
: metric;

const renderTooltip = ({ active, payload }: any) => {
if (!active || !payload || payload.length === 0) return null;
const point = payload[0]?.payload as UsageTrendChartPoint | undefined;
Expand Down Expand Up @@ -282,6 +299,16 @@ export function UsageTrendChart({
{ value: "cost", label: t("usage.trend.cost") },
]}
/>
<SegmentedControl<ChartType>
size="sm"
aria-label={t("usage.trend.chartTypeLabel")}
value={chartType}
onValueChange={setChartType}
items={[
{ value: "bar", label: t("usage.trend.bar") },
{ value: "area", label: t("usage.trend.area") },
]}
/>
</div>

<div className="mt-1 h-[132px] w-full">
Expand All @@ -291,7 +318,7 @@ export function UsageTrendChart({
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart
<Chart
data={chartData}
margin={{ top: 6, right: 0, left: 0, bottom: 0 }}
>
Expand Down Expand Up @@ -323,23 +350,36 @@ export function UsageTrendChart({
/>
<Tooltip
content={renderTooltip}
cursor={{ fill: "var(--bg-subtle)" }}
/>
<Bar
// 成本先换算成显示币种再画,Y 轴才会按这个币种挑整齐的刻度
dataKey={
metric === "cost"
? (point: UsageTrendChartPoint) =>
point.cost == null ? null : point.cost * rate
: metric
cursor={
chartType === "bar"
? { fill: "var(--bg-subtle)" }
: { stroke: "var(--text-3)", strokeDasharray: "3 3" }
}
name={metricLabel}
fill="var(--chart-1)"
radius={[3, 3, 0, 0]}
maxBarSize={36}
isAnimationActive={false}
/>
</BarChart>
{chartType === "bar" ? (
<Bar
dataKey={dataKey}
name={metricLabel}
fill="var(--chart-1)"
radius={[3, 3, 0, 0]}
maxBarSize={36}
isAnimationActive={false}
/>
) : (
<Area
type="monotone"
dataKey={dataKey}
name={metricLabel}
stroke="var(--chart-1)"
strokeWidth={2}
fill="var(--chart-1)"
fillOpacity={0.16}
dot={chartData.length === 1 ? { r: 3 } : false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
)}
</Chart>
</ResponsiveContainer>
)}
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/components/usage/rememberedChoice.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { useState } from "react";

/**
* 用量页「上次的选择」(页签、时间范围、趋势 / 热力图指标)记在本机 localStorage:
* 用量页「上次的选择」(页签、时间范围、图表样式、趋势 / 热力图指标)记在本机 localStorage:
* 只是界面偏好,不进 settings.json、不随云同步。读不到、写不了(隐私模式)或值认不出时按默认值。
*/
export const USAGE_CHOICE_KEYS = {
tab: "cc-switch.usage.tab",
range: "cc-switch.usage.rangePreset",
trendMetric: "cc-switch.usage.trendMetric",
trendChartType: "cc-switch.usage.trendChartType",
heatmapMetric: "cc-switch.usage.heatmapMetric",
} as const;

Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2422,6 +2422,9 @@
"trend": {
"title": "Usage trend · {{range}}",
"metricLabel": "Trend metric",
"chartTypeLabel": "Chart style",
"bar": "Bar",
"area": "Area",
"requests": "Requests",
"tokens": "Tokens",
"cost": "Cost",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -2422,6 +2422,9 @@
"trend": {
"title": "使用状況の推移 · {{range}}",
"metricLabel": "推移の指標",
"chartTypeLabel": "グラフの種類",
"bar": "棒グラフ",
"area": "面グラフ",
"requests": "リクエスト",
"tokens": "Tokens",
"cost": "コスト",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -2423,6 +2423,9 @@
"trend": {
"title": "使用趨勢 · {{range}}",
"metricLabel": "趨勢指標",
"chartTypeLabel": "圖表樣式",
"bar": "柱狀圖",
"area": "曲線面積圖",
"requests": "請求",
"tokens": "Tokens",
"cost": "成本",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -2422,6 +2422,9 @@
"trend": {
"title": "使用趋势 · {{range}}",
"metricLabel": "趋势指标",
"chartTypeLabel": "图表样式",
"bar": "柱形图",
"area": "曲线面积图",
"requests": "请求",
"tokens": "Tokens",
"cost": "成本",
Expand Down
96 changes: 95 additions & 1 deletion tests/components/UsageTrendChart.render.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { screen, within } from "@testing-library/react";
import { renderWithQueryClient as render } from "../utils/testQueryClient";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { UsageTrendChart } from "@/components/usage/UsageTrendChart";
import { USAGE_CHOICE_KEYS } from "@/components/usage/rememberedChoice";

vi.mock("recharts", async (importOriginal) => {
const recharts = await importOriginal<typeof import("recharts")>();
return {
...recharts,
// jsdom has no layout; keep the actual charts with fixed container dimensions.
ResponsiveContainer: (
props: ComponentProps<typeof recharts.ResponsiveContainer>,
) => <recharts.ResponsiveContainer {...props} width={640} height={132} />,
};
});

vi.mock("react-i18next", () => ({
useTranslation: () => ({
Expand Down Expand Up @@ -74,3 +87,84 @@ describe("UsageTrendChart (single metric)", () => {
);
});
});

describe("UsageTrendChart styles", () => {
const props = {
range: { preset: "7d" as const },
rangeLabel: "7d",
refreshIntervalMs: 0,
};

it.each(["tokens", "requests", "cost"] as const)(
"switches between bar and area charts while keeping the %s metric",
async (metric) => {
const user = userEvent.setup();
const { container } = render(<UsageTrendChart {...props} />);
await user.click(
screen.getByRole("button", { name: `usage.trend.${metric}` }),
);
const controls = within(
screen.getByRole("group", { name: "usage.trend.chartTypeLabel" }),
);
const bar = controls.getByRole("button", { name: "usage.trend.bar" });
const area = controls.getByRole("button", { name: "usage.trend.area" });
const legend = screen.getByTestId("usage-trend-legend").textContent;

expect(bar).toHaveAttribute("aria-pressed", "true");
expect(container.querySelector(".recharts-bar-rectangle")).not.toBeNull();

await user.click(area);
expect(area).toHaveAttribute("aria-pressed", "true");
expect(bar).toHaveAttribute("aria-pressed", "false");
expect(container.querySelector(".recharts-area")).not.toBeNull();
// A one-bucket range must still show a point in area mode.
expect(container.querySelector(".recharts-area-dot")).not.toBeNull();
expect(container.querySelector(".recharts-bar-rectangle")).toBeNull();
expect(screen.getByTestId("usage-trend-legend").textContent).toBe(legend);

await user.click(bar);
expect(bar).toHaveAttribute("aria-pressed", "true");
expect(container.querySelector(".recharts-bar-rectangle")).not.toBeNull();
expect(container.querySelector(".recharts-area")).toBeNull();
expect(screen.getByTestId("usage-trend-legend").textContent).toBe(legend);
},
);

it("remembers the chart style independently of the metric and filters", async () => {
const user = userEvent.setup();
const first = render(<UsageTrendChart {...props} />);
await user.click(screen.getByRole("button", { name: "usage.trend.area" }));
await user.click(screen.getByRole("button", { name: "usage.trend.cost" }));

first.rerender(
<UsageTrendChart
{...props}
range={{ preset: "1d" }}
appType="codex"
model="gpt-5"
/>,
);
expect(
screen.getByRole("button", { name: "usage.trend.area" }),
).toHaveAttribute("aria-pressed", "true");
first.unmount();

render(<UsageTrendChart {...props} />);
expect(
screen.getByRole("button", { name: "usage.trend.area" }),
).toHaveAttribute("aria-pressed", "true");
expect(screen.getByTestId("usage-trend-legend")).toHaveTextContent(
"usage.trend.cost",
);
});

it("falls back to a bar chart for an unknown saved style", () => {
localStorage.setItem(USAGE_CHOICE_KEYS.trendChartType, "unknown");
const { container } = render(<UsageTrendChart {...props} />);

expect(
screen.getByRole("button", { name: "usage.trend.bar" }),
).toHaveAttribute("aria-pressed", "true");
expect(container.querySelector(".recharts-bar-rectangle")).not.toBeNull();
});
});
Loading