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:
1 parent
3dd1fd93fe
commit
a1fdc6c93d
16 files changed
+1190
-506
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user