Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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