feat(dashboard): 用量统计新增「昨天」范围 + 跨供应商「模型用量统计」选项卡

- 范围新增 yesterday:本地时区昨日 0 点 ~ 今日 0 点,range_end 引入上界过滤
- RangeStats 新增 modelsByName:按裸模型名聚合,同一模型跨供应商合并,
  子代理记录归入 __secondary__ 单独成行,按 Token 降序
- 趋势选项卡第 4 个 tab「模型用量统计」:新增 ModelTotalsChart 横向条形图,
  悬停显示请求数/Token/缓存命中/费用
- 概览卡网格扩展为 5 张;i18n 中英同步
- 顺带:pricing 测试基准从已下架的 moonshotai/kimi-k2.5 切换到 kimi-k3
This commit is contained in:
KimiSwitch Dev committed 2026-09-09 13:51:43 +08:00
1 parent 86112ee90b
commit 35fd2e7b0e
7 files changed
+252 -29

No files matched your search

+25 -6
View File
@@ -8,10 +8,11 @@ import { fmtInt, fmtPct, fmtTime, fmtTokens } from "../../lib/dashboard-format";
import { DailyBars } from "./DailyBars";
import { DailyDetailModal } from "./DailyDetailModal";
import { Heatmap } from "./Heatmap";
import { ModelTotalsChart } from "./ModelTotalsChart";
import { TrendLineChart } from "./TrendLineChart";
import type { DailyRow, HeatmapCell } from "../../types/dashboard";
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
const RANGES: DashboardRange[] = ["today", "yesterday", "7d", "30d", "all"];
/** Per-request cache hit rate: cached tokens / total input tokens. */
function cacheHitOf(r: { inputOther: number; inputCacheRead: number }): number {
@@ -59,13 +60,14 @@ export function DashboardPage() {
const { range, changeRange, data, loading, error, refresh, loadStats } = useDashboard();
const [showAllModels, setShowAllModels] = useState(false);
const [recentPage, setRecentPage] = useState(1);
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider" | "totals">("daily");
const [heatmapSelected, setHeatmapSelected] = useState<DailyRow | null>(null);
const daily = data?.stats.daily ?? [];
const RANGE_LABELS: Record<DashboardRange, string> = {
today: t("rangeToday"),
yesterday: t("rangeYesterday"),
"7d": t("range7d"),
"30d": t("range30d"),
all: t("rangeAll"),
@@ -192,7 +194,7 @@ export function DashboardPage() {
</div>
{/* Range overview cards */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{RANGES.map((r) => {
const tot = rangeTotals[r];
if (!tot) return null;
@@ -272,13 +274,26 @@ export function DashboardPage() {
>
{t("tabProviderTrend")}
</button>
<button
type="button"
onClick={() => setTrendTab("totals")}
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
trendTab === "totals"
? "border-blue-500 text-content-primary"
: "border-transparent text-content-muted hover:text-content-primary"
}`}
>
{t("tabModelTotals")}
</button>
</div>
<span className="pb-2 text-xs text-content-muted">
{trendTab === "daily"
? t("trendLineSub", { n: trendDaily.length })
: daily.length > 30
? t("cardDailyTrendSubCapped", { n: daily.length })
: t("cardDailyTrendSub", { n: daily.length })}
: trendTab === "totals"
? t("cardModelUsageSub", { n: data.stats.modelsByName.length })
: daily.length > 30
? t("cardDailyTrendSubCapped", { n: daily.length })
: t("cardDailyTrendSub", { n: daily.length })}
</span>
</div>
{/* Active tab content */}
@@ -287,6 +302,10 @@ export function DashboardPage() {
<div className="flex-1 min-h-[240px]">
<TrendLineChart daily={trendDaily} />
</div>
) : trendTab === "totals" ? (
<div className="flex-1 min-h-[240px]">
<ModelTotalsChart rows={data.stats.modelsByName} />
</div>
) : (
<div className="flex-1 min-h-[240px]">
<DailyBars
@@ -0,0 +1,73 @@
import { useTranslation } from "../../i18n";
import { useCurrency } from "../../hooks/useCurrency";
import { fmtInt, fmtPct, fmtTokens } from "../../lib/dashboard-format";
import { modelColor } from "./DailyBars";
import type { ModelRow } from "../../types/dashboard";
interface ModelTotalsChartProps {
/** Provider-agnostic model totals, pre-sorted by totalTokens desc. */
rows: ModelRow[];
}
/** Horizontal ranked bars: one row per bare model name, providers merged. */
export function ModelTotalsChart({ rows }: ModelTotalsChartProps) {
const { t } = useTranslation();
const { money } = useCurrency();
if (!rows.length) {
return (
<div className="flex h-48 items-center justify-center text-sm text-content-muted">
{t("noData")}
</div>
);
}
const max = Math.max(...rows.map((m) => m.totalTokens || 0), 1);
return (
<div className="flex h-full flex-col gap-1 overflow-y-auto">
{rows.map((m, i) => {
const color = i < 10 ? modelColor(i) : "#6b7280";
const name = m.isSecondary ? t("subagentModel") : m.modelDisplay || m.model;
const tooltip = [
name,
`${t("kpiRequests")}: ${fmtInt(m.requests)}`,
`${t("totalTokens")}: ${fmtInt(m.totalTokens)}`,
`${t("colCacheHit")}: ${fmtPct(m.cacheHitRate)}`,
`${t("cost")}: ${money(m.costUsd)}`,
].join("\n");
return (
<div
key={m.model}
title={tooltip}
className="group flex items-center gap-2 rounded px-1 py-1 cursor-help hover:bg-hover"
>
<span
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
style={{ backgroundColor: color }}
/>
<span className="w-40 shrink-0 truncate text-xs text-content-primary">
{name}
{m.costEstimated && (
<span className="ml-1 text-[10px] text-yellow-600">({t("estimate")})</span>
)}
</span>
<div className="flex h-4 flex-1 items-center overflow-hidden rounded-sm bg-hover-2/50">
<div
className="h-full rounded-sm transition-[width] group-hover:opacity-85"
style={{
width: `${Math.max(2, (m.totalTokens / max) * 100)}%`,
backgroundColor: color,
}}
/>
</div>
<span className="w-16 shrink-0 text-right text-xs text-content-muted">
{fmtTokens(m.totalTokens)}
</span>
</div>
);
})}
</div>
);
}