diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index 36ffad2428c..d9f1f85a599 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -864,7 +864,7 @@ export function UsageDashboard({ compact={compact} /> - {/* 「全部」看长期分布用热力图;24 小时到 30 天这类短范围用柱状图 */} + {/* 「全部」看长期分布用热力图;24 小时到 30 天这类短范围用趋势图 */} {range.preset === "all" ? ( ( + USAGE_CHOICE_KEYS.trendChartType, + CHART_TYPES, + "bar", + ); const { startDate, endDate } = resolveUsageRange(range); const { data: trends, isLoading } = useUsageTrends( range, @@ -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; @@ -282,6 +299,16 @@ export function UsageTrendChart({ { value: "cost", label: t("usage.trend.cost") }, ]} /> + + 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") }, + ]} + />
@@ -291,7 +318,7 @@ export function UsageTrendChart({
) : ( - @@ -323,23 +350,36 @@ export function UsageTrendChart({ /> - - 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} /> - + {chartType === "bar" ? ( + + ) : ( + + )} + )} diff --git a/src/components/usage/rememberedChoice.ts b/src/components/usage/rememberedChoice.ts index 4e2857fcdd7..e9eabb3b5e7 100644 --- a/src/components/usage/rememberedChoice.ts +++ b/src/components/usage/rememberedChoice.ts @@ -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; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b6466cddea1..505b448aab2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d1e0de6eb84..a0b3128dbc3 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2422,6 +2422,9 @@ "trend": { "title": "使用状況の推移 · {{range}}", "metricLabel": "推移の指標", + "chartTypeLabel": "グラフの種類", + "bar": "棒グラフ", + "area": "面グラフ", "requests": "リクエスト", "tokens": "Tokens", "cost": "コスト", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 653ff4059de..962a656d46f 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2423,6 +2423,9 @@ "trend": { "title": "使用趨勢 · {{range}}", "metricLabel": "趨勢指標", + "chartTypeLabel": "圖表樣式", + "bar": "柱狀圖", + "area": "曲線面積圖", "requests": "請求", "tokens": "Tokens", "cost": "成本", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c88754b06a4..bceec84b1c5 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2422,6 +2422,9 @@ "trend": { "title": "使用趋势 · {{range}}", "metricLabel": "趋势指标", + "chartTypeLabel": "图表样式", + "bar": "柱形图", + "area": "曲线面积图", "requests": "请求", "tokens": "Tokens", "cost": "成本", diff --git a/tests/components/UsageTrendChart.render.test.tsx b/tests/components/UsageTrendChart.render.test.tsx index ce4608f0f15..5426b53ebc4 100644 --- a/tests/components/UsageTrendChart.render.test.tsx +++ b/tests/components/UsageTrendChart.render.test.tsx @@ -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(); + return { + ...recharts, + // jsdom has no layout; keep the actual charts with fixed container dimensions. + ResponsiveContainer: ( + props: ComponentProps, + ) => , + }; +}); vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -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(); + 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(); + await user.click(screen.getByRole("button", { name: "usage.trend.area" })); + await user.click(screen.getByRole("button", { name: "usage.trend.cost" })); + + first.rerender( + , + ); + expect( + screen.getByRole("button", { name: "usage.trend.area" }), + ).toHaveAttribute("aria-pressed", "true"); + first.unmount(); + + render(); + 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(); + + expect( + screen.getByRole("button", { name: "usage.trend.bar" }), + ).toHaveAttribute("aria-pressed", "true"); + expect(container.querySelector(".recharts-bar-rectangle")).not.toBeNull(); + }); +});