完善UI
This commit is contained in:
218
src/components/TokenUsageTrend.tsx
Normal file
218
src/components/TokenUsageTrend.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { DailyUsage } from "@/lib/api";
|
||||
|
||||
interface TokenUsageTrendProps {
|
||||
days: DailyUsage[];
|
||||
}
|
||||
|
||||
// Simple number formatters
|
||||
const fmtTokens = (n: number) => {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return `${n}`;
|
||||
};
|
||||
const fmtUSD = (n: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 4 }).format(n);
|
||||
|
||||
/**
|
||||
* A lightweight multi-series line/area chart implemented with SVG and basic UI primitives.
|
||||
* - Left axis: Tokens (input/output/cache write/cache read)
|
||||
* - Right axis: Cost (USD) and Requests count (normalized to its own max)
|
||||
* - Tooltip closely matches the screenshot content
|
||||
*/
|
||||
export const TokenUsageTrend: React.FC<TokenUsageTrendProps> = ({ days }) => {
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
|
||||
const { labels, series, maxTokens, maxCost, maxReq } = useMemo(() => {
|
||||
const sorted = days.slice().reverse(); // chronological left->right
|
||||
const labels = sorted.map((d) =>
|
||||
new Date(d.date.replace(/-/g, "/")).toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" })
|
||||
);
|
||||
const series = {
|
||||
input: sorted.map((d) => d.input_tokens || 0),
|
||||
output: sorted.map((d) => d.output_tokens || 0),
|
||||
cacheW: sorted.map((d) => d.cache_creation_tokens || 0),
|
||||
cacheR: sorted.map((d) => d.cache_read_tokens || 0),
|
||||
cost: sorted.map((d) => d.total_cost || 0),
|
||||
reqs: sorted.map((d) => d.request_count || 0),
|
||||
sumTokens: sorted.map(
|
||||
(d) => (d.input_tokens || 0) + (d.output_tokens || 0) + (d.cache_creation_tokens || 0) + (d.cache_read_tokens || 0)
|
||||
),
|
||||
} as const;
|
||||
const maxTokens = Math.max(1, ...series.sumTokens, ...series.input, ...series.output, ...series.cacheW, ...series.cacheR);
|
||||
const maxCost = Math.max(1, ...series.cost);
|
||||
const maxReq = Math.max(1, ...series.reqs);
|
||||
return { labels, series, maxTokens, maxCost, maxReq };
|
||||
}, [days]);
|
||||
|
||||
const width = 900;
|
||||
const height = 260;
|
||||
const padL = 56; // room for left ticks
|
||||
const padR = 56; // room for right ticks
|
||||
const padT = 16;
|
||||
const padB = 36;
|
||||
const plotW = width - padL - padR;
|
||||
const plotH = height - padT - padB;
|
||||
|
||||
const n = labels.length;
|
||||
const x = (i: number) => padL + (plotW * i) / Math.max(1, n - 1);
|
||||
const yToken = (v: number) => padT + plotH * (1 - v / maxTokens);
|
||||
const yCost = (v: number) => padT + plotH * (1 - v / maxCost);
|
||||
const yReq = (v: number) => padT + plotH * (1 - v / maxReq);
|
||||
|
||||
const pathFrom = (vals: number[], y: (v: number) => number) =>
|
||||
vals.map((v, i) => `${i === 0 ? "M" : "L"} ${x(i)} ${y(v)}`).join(" ");
|
||||
|
||||
const colors = {
|
||||
input: "#3b82f6", // blue-500
|
||||
output: "#ec4899", // pink-500
|
||||
cacheW: "#60a5fa", // blue-400
|
||||
cacheR: "#a78bfa", // violet-400
|
||||
cost: "#22c55e", // green-500
|
||||
req: "#16a34a", // green-600
|
||||
grid: "var(--border)",
|
||||
text: "var(--muted-foreground)",
|
||||
} as const;
|
||||
|
||||
const hovered = hoverIndex != null ? hoverIndex : null;
|
||||
|
||||
const renderTooltip = () => {
|
||||
if (hovered == null) return null;
|
||||
const dateText = new Date(days.slice().reverse()[hovered].date.replace(/-/g, "/")).toLocaleDateString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const d = days.slice().reverse()[hovered];
|
||||
return (
|
||||
<div className="absolute -translate-x-1/2 bottom-full mb-2 left-1/2 pointer-events-none">
|
||||
<div className="bg-background border border-border rounded-lg shadow-lg p-3 text-xs whitespace-nowrap">
|
||||
<div className="text-sm font-semibold mb-1">{dateText}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.cost }} />
|
||||
费用(USD):{fmtUSD(d.total_cost)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.cacheR }} />
|
||||
缓存读取Token: {fmtTokens(d.cache_read_tokens || 0)} tokens
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.cacheW }} />
|
||||
缓存创建Token: {fmtTokens(d.cache_creation_tokens || 0)} tokens
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.output }} />
|
||||
输出Token: {fmtTokens(d.output_tokens || 0)} tokens
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.input }} />
|
||||
输入Token: {fmtTokens(d.input_tokens || 0)} tokens
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-sm" style={{ background: colors.req }} />
|
||||
请求数:{d.request_count || 0} 次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">Token使用趋势</h3>
|
||||
<div className="relative w-full overflow-x-auto">
|
||||
<svg width={width} height={height} className="min-w-[900px]">
|
||||
{/* axes */}
|
||||
<line x1={padL} y1={padT} x2={padL} y2={padT + plotH} stroke={colors.grid} />
|
||||
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke={colors.grid} />
|
||||
{/* left ticks (tokens) 0, 25%, 50%, 75%, 100% */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((t) => (
|
||||
<g key={t}>
|
||||
<text x={8} y={padT + plotH * (1 - t)} className="text-[10px]" fill={colors.text}>
|
||||
{fmtTokens(Math.round(maxTokens * t))}
|
||||
</text>
|
||||
<line
|
||||
x1={padL}
|
||||
y1={padT + plotH * (1 - t)}
|
||||
x2={padL + plotW}
|
||||
y2={padT + plotH * (1 - t)}
|
||||
stroke={colors.grid}
|
||||
strokeDasharray="2,4"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
{/* right ticks (cost/requests) */}
|
||||
{[0, 0.5, 1].map((t) => (
|
||||
<g key={`r-${t}`}>
|
||||
<text x={padL + plotW + 4} y={padT + plotH * (1 - t)} className="text-[10px]" fill={colors.text}>
|
||||
{t === 1 ? fmtUSD(maxCost) : t === 0.5 ? fmtUSD(maxCost / 2) : "$0"}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* token lines */}
|
||||
<path d={pathFrom(series.input, yToken)} fill="none" stroke={colors.input} strokeWidth={2} />
|
||||
<path d={pathFrom(series.output, yToken)} fill="none" stroke={colors.output} strokeWidth={2} />
|
||||
<path d={pathFrom(series.cacheW, yToken)} fill="none" stroke={colors.cacheW} strokeWidth={2} />
|
||||
<path d={pathFrom(series.cacheR, yToken)} fill="none" stroke={colors.cacheR} strokeWidth={2} />
|
||||
|
||||
{/* cost line (right axis) */}
|
||||
<path d={pathFrom(series.cost, yCost)} fill="none" stroke={colors.cost} strokeWidth={2} />
|
||||
|
||||
{/* requests as small circles on right scale */}
|
||||
{series.reqs.map((v, i) => (
|
||||
<circle key={`req-${i}`} cx={x(i)} cy={yReq(v)} r={2.5} fill={colors.req} />
|
||||
))}
|
||||
|
||||
{/* x labels and hover hit-areas */}
|
||||
{labels.map((lab, i) => (
|
||||
<g key={i}
|
||||
onMouseEnter={() => setHoverIndex(i)}
|
||||
onMouseLeave={() => setHoverIndex(null)}>
|
||||
<text
|
||||
x={x(i)}
|
||||
y={padT + plotH + 16}
|
||||
textAnchor="middle"
|
||||
className="text-[10px]"
|
||||
fill={colors.text}
|
||||
>
|
||||
{lab}
|
||||
</text>
|
||||
{/* vertical hover guide */}
|
||||
{hoverIndex === i && (
|
||||
<line x1={x(i)} y1={padT} x2={x(i)} y2={padT + plotH} stroke={colors.grid} />
|
||||
)}
|
||||
{/* invisible hit area */}
|
||||
<rect x={x(i) - plotW / Math.max(1, n - 1) / 2}
|
||||
y={padT}
|
||||
width={plotW / Math.max(1, n - 1)}
|
||||
height={plotH}
|
||||
fill="transparent" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
{/* Tooltip container */}
|
||||
{hoverIndex != null && (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: `${((padL + (plotW * hoverIndex) / Math.max(1, n - 1)) / width) * 100}%`, bottom: padB + 8 }}
|
||||
>
|
||||
{renderTooltip()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* legend */}
|
||||
<div className="flex flex-wrap gap-4 mt-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.input }} />输入Token</div>
|
||||
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.output }} />输出Token</div>
|
||||
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.cacheW }} />缓存创建Token</div>
|
||||
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.cacheR }} />缓存读取Token</div>
|
||||
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.cost }} />费用(USD)</div>
|
||||
<div className="flex items-center gap-2"><span className="inline-block w-3 h-1" style={{ background: colors.req }} />请求数</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user