feat: v0.5.2 - daily trend refactor + provider/model two-level drill-down

- Dashboard: 用量趋势卡片改为外层 tab 容器(下划线 tab),移除卡片标题与内部 pill 切换器
- 用量趋势: 新增 供应商模型用量趋势 tab,与 模型用量趋势 并列
- 供应商 tab 弹窗: 每日用量详情改为两级(供应商 → 模型)分组
- 后端: DailyRow 新增 by_provider_model 嵌套聚合
- 前端: DailyBars 通用化(dimension/colorMap),DailyDetailModal 按 tab 维度渲染
- i18n: 新增 providerDistribution(中/英)
- 版本: 0.5.1 → 0.5.2(tauri.conf / Cargo.toml / package.json / README)
This commit is contained in:
KimiSwitch Dev committed 2026-07-29 13:41:04 +08:00
1 parent 3dd1fd93fe
commit a1fdc6c93d
16 files changed
+1190 -506

No files matched your search

+120 -71
View File
@@ -1,16 +1,12 @@
import { useState, type ReactNode } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
import { useTranslation } from "../../i18n";
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import { DailyBars } from "./DailyBars";
import { Heatmap } from "./Heatmap";
const RANGES: { value: DashboardRange; label: string }[] = [
{ value: "today", label: "今天" },
{ value: "7d", label: "7 天" },
{ value: "30d", label: "30 天" },
{ value: "all", label: "全部" },
];
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
function Card({
title,
@@ -45,9 +41,18 @@ function KpiCard({ label, value, sub }: { label: string; value: string; sub?: st
}
export function DashboardPage() {
const { t } = useTranslation();
const { range, changeRange, data, loading, error, refresh } = useDashboard();
const [showAllModels, setShowAllModels] = useState(false);
const [recentPage, setRecentPage] = useState(1);
const [trendTab, setTrendTab] = useState<"model" | "provider">("model");
const RANGE_LABELS: Record<DashboardRange, string> = {
today: t("rangeToday"),
"7d": t("range7d"),
"30d": t("range30d"),
all: t("rangeAll"),
};
if (loading && !data) {
return (
@@ -61,13 +66,13 @@ export function DashboardPage() {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="text-red-400 mb-2">加载失败</div>
<div className="text-red-400 mb-2">{t("dashLoadFailed")}</div>
<div className="text-sm text-content-muted">{error}</div>
<button
onClick={() => refresh()}
className="mt-4 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm"
>
重试
{t("retry")}
</button>
</div>
</div>
@@ -94,16 +99,26 @@ export function DashboardPage() {
// Ordered model names for chart colour mapping
const modelNames = models.map((m) => m.model);
const trendDaily = daily.length > 30 ? daily.slice(-30) : daily;
const providerTotals: Record<string, number> = {};
for (const d of trendDaily) {
for (const [p, tok] of Object.entries(d.byProvider || {})) {
providerTotals[p] = (providerTotals[p] || 0) + tok;
}
}
const providerNames = Object.entries(providerTotals)
.sort((a, b) => b[1] - a[1])
.map(([p]) => p);
const kpiItems = [
{ label: "请求数", value: fmtInt(totals.requests) },
{ label: "非缓存输入", value: fmtTokens(totals.inputOther), sub: fmtInt(totals.inputOther) },
{ label: "输出", value: fmtTokens(totals.output), sub: fmtInt(totals.output) },
{ label: "缓存读", value: fmtTokens(totals.inputCacheRead), sub: fmtInt(totals.inputCacheRead) },
{ label: "缓存创建", value: fmtTokens(totals.inputCacheCreation), sub: fmtInt(totals.inputCacheCreation) },
{ label: "缓存命中", value: fmtPct(totals.cacheHitRate) },
{ label: "总 Token", value: fmtTokens(totals.totalTokens), sub: fmtInt(totals.totalTokens) },
{ label: "预估费用", value: fmtUsd(totals.costUsd) },
{ label: t("kpiRequests"), value: fmtInt(totals.requests) },
{ label: t("kpiInputOther"), value: fmtTokens(totals.inputOther), sub: fmtInt(totals.inputOther) },
{ label: t("kpiOutput"), value: fmtTokens(totals.output), sub: fmtInt(totals.output) },
{ label: t("kpiCacheRead"), value: fmtTokens(totals.inputCacheRead), sub: fmtInt(totals.inputCacheRead) },
{ label: t("kpiCacheCreation"), value: fmtTokens(totals.inputCacheCreation), sub: fmtInt(totals.inputCacheCreation) },
{ label: t("kpiCacheHit"), value: fmtPct(totals.cacheHitRate) },
{ label: t("totalTokens"), value: fmtTokens(totals.totalTokens), sub: fmtInt(totals.totalTokens) },
{ label: t("kpiCost"), value: fmtUsd(totals.costUsd) },
];
return (
@@ -113,16 +128,16 @@ export function DashboardPage() {
<div className="flex items-center bg-input border border-border rounded p-0.5">
{RANGES.map((r) => (
<button
key={r.value}
key={r}
type="button"
onClick={() => changeRange(r.value)}
onClick={() => changeRange(r)}
className={`px-3 py-1 text-sm rounded transition-colors ${
range === r.value
range === r
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{r.label}
{RANGE_LABELS[r]}
</button>
))}
</div>
@@ -132,33 +147,33 @@ export function DashboardPage() {
disabled={loading}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 text-content-muted hover:text-content-primary disabled:opacity-50"
>
{loading ? "刷新中…" : "↻ 刷新"}
{loading ? t("refreshing") : `↻ ${t("refresh")}`}
</button>
</div>
{/* Range overview cards */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{RANGES.map((r) => {
const tot = rangeTotals[r.value];
const tot = rangeTotals[r];
if (!tot) return null;
const active = range === r.value;
const active = range === r;
return (
<button
key={r.value}
key={r}
type="button"
onClick={() => changeRange(r.value)}
onClick={() => changeRange(r)}
className={`rounded-xl border p-4 text-left transition-colors ${
active
? "border-blue-500/50 ring-1 ring-blue-500/30 bg-blue-900/10"
: "border-border bg-panel hover:border-strong hover:bg-hover"
}`}
>
<div className="text-xs text-content-muted">{r.label}</div>
<div className="text-xs text-content-muted">{RANGE_LABELS[r]}</div>
<div className="mt-1 text-lg font-semibold text-content-primary tracking-tight">
{fmtTokens(tot.totalTokens)}
</div>
<div className="mt-1 text-[11px] text-content-muted">
{fmtInt(tot.requests)} 次 · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
{fmtInt(tot.requests)} {t("unitTimes")} · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
</div>
</button>
);
@@ -173,41 +188,72 @@ export function DashboardPage() {
</div>
{/* Heatmap — full-year strip, fixed-size cells, centered */}
<Card title="全年热力图" subtitle="按 Token 量着色">
<Card title={t("cardHeatmap")} subtitle={t("cardHeatmapSub")}>
<Heatmap heatmap={heatmap} />
</Card>
{/* Charts + Model side table */}
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
<Card
title="每日用量趋势"
subtitle={
daily.length > 30
? `近 30 天 · 共 ${daily.length} 天`
: `按模型着色 · ${daily.length} 天`
}
>
<div className="h-full min-h-[240px]">
<DailyBars
daily={daily.length > 30 ? daily.slice(-30) : daily}
modelNames={modelNames}
/>
{/* Usage-trend tab container */}
<div className="flex flex-col rounded-xl border border-border bg-panel">
{/* Tab bar header */}
<div className="flex items-end justify-between border-b border-border px-2">
<div className="flex gap-1">
<button
type="button"
onClick={() => setTrendTab("model")}
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
trendTab === "model"
? "border-blue-500 text-content-primary"
: "border-transparent text-content-muted hover:text-content-primary"
}`}
>
{t("tabModelTrend")}
</button>
<button
type="button"
onClick={() => setTrendTab("provider")}
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
trendTab === "provider"
? "border-blue-500 text-content-primary"
: "border-transparent text-content-muted hover:text-content-primary"
}`}
>
{t("tabProviderTrend")}
</button>
</div>
<span className="pb-2 text-xs text-content-muted">
{daily.length > 30
? t("cardDailyTrendSubCapped", { n: daily.length })
: t("cardDailyTrendSub", { n: daily.length })}
</span>
</div>
</Card>
{/* Active tab content */}
<div className="flex-1 p-4">
<div className="h-full min-h-[240px]">
<DailyBars
daily={trendDaily}
dimension={trendTab}
names={trendTab === "model" ? modelNames : providerNames}
unknownProviderLabel={t("providerUnknown")}
/>
</div>
</div>
</div>
<Card title="模型用量" subtitle={`${models.length} 个模型`}>
<Card title={t("cardModelUsage")} subtitle={t("cardModelUsageSub", { n: models.length })}>
{models.length === 0 ? (
<div className="py-8 text-center text-sm text-content-muted">暂无数据</div>
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
) : (
<div className="max-h-[360px] overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-panel">
<tr className="text-left text-xs text-content-muted border-b border-border">
<th className="pb-2 pr-3 font-normal">模型</th>
<th className="pb-2 pr-3 font-normal text-right">请求</th>
<th className="pb-2 pr-3 font-normal text-right">Token</th>
<th className="pb-2 pr-3 font-normal text-right">缓存命中</th>
<th className="pb-2 font-normal text-right">费用</th>
<th className="pb-2 pr-3 font-normal">{t("colModel")}</th>
<th className="pb-2 pr-3 font-normal text-right">{t("requests")}</th>
<th className="pb-2 pr-3 font-normal text-right">{t("colTokens")}</th>
<th className="pb-2 pr-3 font-normal text-right">{t("colCacheHit")}</th>
<th className="pb-2 font-normal text-right">{t("cost")}</th>
</tr>
</thead>
<tbody>
@@ -230,7 +276,7 @@ export function DashboardPage() {
<div className="min-w-0">
<div className="text-content-primary whitespace-nowrap">{m.modelDisplay || m.model}</div>
{m.costEstimated && (
<span className="text-[10px] text-yellow-600">估算</span>
<span className="text-[10px] text-yellow-600">{t("estimate")}</span>
)}
</div>
</div>
@@ -249,7 +295,7 @@ export function DashboardPage() {
onClick={() => setShowAllModels(!showAllModels)}
className="mt-3 text-sm text-blue-500 hover:text-blue-400"
>
{showAllModels ? "收起" : `展开全部 (${models.length})`}
{showAllModels ? t("collapse") : t("expandAll", { n: models.length })}
</button>
)}
</div>
@@ -259,27 +305,27 @@ export function DashboardPage() {
{/* Recent requests */}
<Card
title="最近请求"
title={t("cardRecent")}
subtitle={
recentTotal > recent.length
? `显示前 ${recent.length} / ${fmtInt(recentTotal)} 条`
: `${recent.length} 条`
? t("cardRecentCapped", { n: recent.length, m: recentTotal })
: t("cardRecentCount", { n: recent.length })
}
>
{recent.length === 0 ? (
<div className="py-8 text-center text-sm text-content-muted">暂无数据</div>
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
) : (
<>
<div className="overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-panel">
<tr className="text-left text-xs text-content-muted border-b border-border">
<th className="pb-2 pr-4 font-normal">时间</th>
<th className="pb-2 pr-4 font-normal">模型</th>
<th className="pb-2 pr-4 font-normal text-right">输入</th>
<th className="pb-2 pr-4 font-normal text-right">输出</th>
<th className="pb-2 pr-4 font-normal text-right">缓存读</th>
<th className="pb-2 font-normal text-right">费用</th>
<th className="pb-2 pr-4 font-normal">{t("colTime")}</th>
<th className="pb-2 pr-4 font-normal">{t("colModel")}</th>
<th className="pb-2 pr-4 font-normal text-right">{t("colInput")}</th>
<th className="pb-2 pr-4 font-normal text-right">{t("colOutput")}</th>
<th className="pb-2 pr-4 font-normal text-right">{t("colCacheRead")}</th>
<th className="pb-2 font-normal text-right">{t("cost")}</th>
</tr>
</thead>
<tbody>
@@ -302,9 +348,12 @@ export function DashboardPage() {
{/* Pagination footer */}
<div className="mt-2 flex items-center justify-between gap-2 border-t border-border pt-2 text-xs text-content-muted">
<span>
第 {fmtInt(recentCurrentPage)} / {fmtInt(recentTotalPages)} 页 ·{" "}
{fmtInt(recentStart + 1)}–{fmtInt(Math.min(recentStart + RECENT_PAGE_SIZE, recent.length))} 条 ·{" "}
每页 {RECENT_PAGE_SIZE} 条
{t("paginationPage", { x: recentCurrentPage, y: recentTotalPages })} ·{" "}
{t("paginationRange", {
x: recentStart + 1,
y: Math.min(recentStart + RECENT_PAGE_SIZE, recent.length),
})}{" "}
· {t("paginationPerPage", { n: RECENT_PAGE_SIZE })}
</span>
<div className="flex items-center gap-1">
<button
@@ -312,7 +361,7 @@ export function DashboardPage() {
disabled={recentCurrentPage <= 1}
onClick={() => setRecentPage(1)}
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="首页"
aria-label={t("pageFirst")}
>
«
</button>
@@ -321,7 +370,7 @@ export function DashboardPage() {
disabled={recentCurrentPage <= 1}
onClick={() => setRecentPage((p) => Math.max(1, p - 1))}
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="上一页"
aria-label={t("pagePrev")}
>
‹
</button>
@@ -330,7 +379,7 @@ export function DashboardPage() {
disabled={recentCurrentPage >= recentTotalPages}
onClick={() => setRecentPage((p) => Math.min(recentTotalPages, p + 1))}
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="下一页"
aria-label={t("pageNext")}
>
›
</button>
@@ -339,7 +388,7 @@ export function DashboardPage() {
disabled={recentCurrentPage >= recentTotalPages}
onClick={() => setRecentPage(recentTotalPages)}
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="末页"
aria-label={t("pageLast")}
>
»
</button>
@@ -351,10 +400,10 @@ export function DashboardPage() {
{/* Attribution footer */}
<div className="text-xs text-content-muted pb-2">
扫描 {data.meta.filesScanned} 个文件 · {fmtInt(data.meta.recordCount)} 条记录 ·{" "}
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
{data.meta.home}
<br />
仪表盘功能基于{" "}
{t("dashboardAttribution")}{" "}
<button
type="button"
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
@@ -362,7 +411,7 @@ export function DashboardPage() {
>
kimicode-dashboard
</button>{" "}
(MIT,© JochenYang)移植
{t("attributionSuffix")}
</div>
</div>
);