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

+1 -1
View File
@@ -303,7 +303,7 @@ export function SettingsModal({
)}
{updateInfo?.updateAvailable && (
<div className="pt-1 text-sm text-green-600 dark:text-green-500">
✦ {t("updateAvailable")}: v{updateInfo.latest}
✦ {t("updateAvailable")}: {formatVersion(updateInfo.latest)}
</div>
)}
{updateInfo && !updateInfo.updateAvailable && (
+32 -19
View File
@@ -5,13 +5,19 @@ import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import type { DailyRow } from "../../types/dashboard";
import { DailyDetailModal } from "./DailyDetailModal";
type TrendDimension = "model" | "provider";
interface DailyBarsProps {
daily: DailyRow[];
/** Ordered model names for the colour palette & legend */
modelNames: string[];
/** Which per-day breakdown to render */
dimension: TrendDimension;
/** Ordered names for the active dimension (colour palette & legend) */
names: string[];
/** Label for the unresolved-provider bucket */
unknownProviderLabel: string;
}
/** Deterministic colour palette — one hue per model index */
/** Deterministic colour palette — one hue per index */
const PALETTE = [
"#3b82f6", // blue
"#22c55e", // green
@@ -35,17 +41,24 @@ function shortModel(model: string): string {
return bare;
}
export function DailyBars({ daily, modelNames }: DailyBarsProps) {
export function DailyBars({ daily, dimension, names, unknownProviderLabel }: DailyBarsProps) {
const { t } = useTranslation();
const [selected, setSelected] = useState<DailyRow | null>(null);
const colorMap = useMemo(() => {
const map: Record<string, string> = {};
modelNames.forEach((m, i) => {
names.forEach((m, i) => {
map[m] = modelColor(i);
});
return map;
}, [modelNames]);
}, [names]);
const displayName = (key: string): string => {
if (dimension === "provider") {
return key === "unknown" ? unknownProviderLabel : key;
}
return shortModel(key);
};
if (!daily?.length) {
return (
@@ -61,13 +74,13 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
<div className="flex h-full flex-col gap-3">
{/* Legend */}
<div className="flex flex-wrap gap-x-4 gap-y-1">
{modelNames.map((m) => (
{names.map((m) => (
<span key={m} className="flex items-center gap-1.5 text-[11px] text-content-muted">
<span
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: colorMap[m] }}
/>
{shortModel(m)}
{displayName(m)}
</span>
))}
</div>
@@ -78,22 +91,20 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
const h = Math.max(4, Math.round(((d.totalTokens || 0) / max) * 170));
const total = d.totalTokens || 1;
// Build stacked segments sorted by token count (largest at bottom)
const segments = Object.entries(d.byModel || {})
.map(([model, tokens]) => ({
model,
const breakdown = dimension === "provider" ? d.byProvider : d.byModel;
const segments = Object.entries(breakdown || {})
.map(([key, tokens]) => ({
key,
tokens,
color: colorMap[model] || "#6b7280",
color: colorMap[key] || "#6b7280",
height: Math.max(1, Math.round((tokens / total) * h)),
}))
.sort((a, b) => b.tokens - a.tokens);
const tooltipLines = [
d.date,
`总 Token: ${fmtTokens(d.totalTokens)} · ${fmtUsd(d.costUsd)} · hit ${fmtPct(d.cacheHitRate)}`,
...segments.map(
(s) => `${shortModel(s.model)}: ${fmtTokens(s.tokens)}`
),
`${t("totalTokens")}: ${fmtTokens(d.totalTokens)} · ${fmtUsd(d.costUsd)} · ${t("colCacheHit")} ${fmtPct(d.cacheHitRate)}`,
...segments.map((s) => `${displayName(s.key)}: ${fmtTokens(s.tokens)}`),
"",
t("doubleClickHint"),
].join("\n");
@@ -112,7 +123,7 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
>
{segments.map((s) => (
<div
key={s.model}
key={s.key}
className="w-full transition-opacity group-hover:opacity-80"
style={{
height: s.height,
@@ -133,11 +144,13 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
createPortal(
<DailyDetailModal
day={selected}
dimension={dimension}
colorMap={colorMap}
unknownProviderLabel={unknownProviderLabel}
onClose={() => setSelected(null)}
/>,
document.body
)}
</div>
);
}
}
+106 -102
View File
@@ -4,10 +4,14 @@ import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import type { DailyRow } from "../../types/dashboard";
import { modelColor } from "./DailyBars";
type TrendDimension = "model" | "provider";
interface DailyDetailModalProps {
day: DailyRow;
/** model name → colour hex */
dimension: TrendDimension;
/** active-dimension name → colour hex (models in model tab, providers in provider tab) */
colorMap: Record<string, string>;
unknownProviderLabel: string;
onClose: () => void;
}
@@ -16,10 +20,9 @@ function shortModel(model: string): string {
return bare;
}
export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalProps) {
export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabel, onClose }: DailyDetailModalProps) {
const { t } = useTranslation();
// ESC to close
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -28,7 +31,6 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// Lock background scroll while modal is open
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
@@ -38,15 +40,35 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
}, []);
const total = day.totalTokens || 1;
const segments = Object.entries(day.byModel || {})
const isProvider = dimension === "provider";
// Flat per-model breakdown (model tab)
const modelSegments = Object.entries(day.byModel || {})
.map(([model, tokens]) => ({
model,
tokens,
pct: (tokens / total) * 100,
color: colorMap[model] || "#6b7280",
}))
.sort((a, b) => b.tokens - a.tokens);
// Two-level provider → model breakdown (provider tab)
const providerGroups = Object.entries(day.byProviderModel || {})
.map(([prov, models]) => {
const provTotal = Object.values(models).reduce((a, b) => a + b, 0);
const modelList = Object.entries(models)
.map(([model, tokens]) => ({ model, tokens }))
.sort((a, b) => b.tokens - a.tokens);
return {
provider: prov,
displayProvider: prov === "unknown" ? unknownProviderLabel : prov,
total: provTotal,
pct: (provTotal / total) * 100,
color: colorMap[prov] || "#6b7280",
models: modelList,
};
})
.sort((a, b) => b.total - a.total);
return (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
@@ -75,17 +97,7 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
aria-label={t("close")}
className="rounded-md p-1 text-content-muted hover:bg-border hover:text-content-primary transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
@@ -93,73 +105,80 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
{/* Day summary chips */}
<div className="grid grid-cols-2 gap-2 px-5 py-3 sm:grid-cols-4">
<SummaryChip
label={t("totalTokens")}
value={fmtTokens(day.totalTokens)}
tone="default"
/>
<SummaryChip
label={t("cost")}
value={fmtUsd(day.costUsd)}
tone="orange"
/>
<SummaryChip
label={t("requests")}
value={Math.round(day.requests).toLocaleString()}
tone="blue"
/>
<SummaryChip
label={t("cacheHitRate")}
value={fmtPct(day.cacheHitRate)}
tone="green"
/>
<SummaryChip label={t("totalTokens")} value={fmtTokens(day.totalTokens)} tone="default" />
<SummaryChip label={t("cost")} value={fmtUsd(day.costUsd)} tone="orange" />
<SummaryChip label={t("requests")} value={Math.round(day.requests).toLocaleString()} tone="blue" />
<SummaryChip label={t("cacheHitRate")} value={fmtPct(day.cacheHitRate)} tone="green" />
</div>
{/* Per-model breakdown */}
{/* Breakdown — flat by model, or two-level by provider → model */}
<div className="px-5 pb-4">
<div className="mb-2 text-[11px] uppercase tracking-wider text-content-muted">
{t("modelDistribution")} · {segments.length}
</div>
{segments.length === 0 ? (
<div className="py-6 text-center text-sm text-content-muted">
{t("noData")}
</div>
) : (
<div className="space-y-1.5 max-h-72 overflow-y-auto pr-1">
{segments.map((s, i) => (
<div key={s.model} className="flex items-center gap-3 text-sm">
<span
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
style={{ backgroundColor: modelColor(i) }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span
className="truncate text-content-primary"
title={s.model}
>
{shortModel(s.model)}
</span>
<span className="shrink-0 text-xs text-content-muted tabular-nums">
{fmtTokens(s.tokens)}{" "}
<span className="text-content-muted">
· {s.pct.toFixed(1)}%
</span>
</span>
{isProvider ? (
<>
<div className="mb-2 text-[11px] uppercase tracking-wider text-content-muted">
{t("providerDistribution")} · {providerGroups.length}
</div>
{providerGroups.length === 0 ? (
<div className="py-6 text-center text-sm text-content-muted">{t("noData")}</div>
) : (
<div className="space-y-3 max-h-72 overflow-y-auto pr-1">
{providerGroups.map((p) => (
<div key={p.provider}>
<div className="flex items-center gap-3 text-sm">
<span className="inline-block h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: p.color }} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-content-primary" title={p.provider}>{p.displayProvider}</span>
<span className="shrink-0 text-xs text-content-muted tabular-nums">
{fmtTokens(p.total)} <span className="text-content-muted">· {p.pct.toFixed(1)}%</span>
</span>
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-border">
<div className="h-full rounded-full" style={{ width: `${Math.max(2, p.pct)}%`, backgroundColor: p.color }} />
</div>
</div>
</div>
<div className="mt-1 ml-5 space-y-0.5">
{p.models.map((m) => (
<div key={m.model} className="flex items-baseline justify-between gap-2 text-xs">
<span className="truncate text-content-muted" title={m.model}>{shortModel(m.model)}</span>
<span className="shrink-0 text-content-muted tabular-nums">{fmtTokens(m.tokens)}</span>
</div>
))}
</div>
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-border">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.max(2, s.pct)}%`,
backgroundColor: modelColor(i),
}}
/>
</div>
</div>
))}
</div>
))}
</div>
)}
</>
) : (
<>
<div className="mb-2 text-[11px] uppercase tracking-wider text-content-muted">
{t("modelDistribution")} · {modelSegments.length}
</div>
{modelSegments.length === 0 ? (
<div className="py-6 text-center text-sm text-content-muted">{t("noData")}</div>
) : (
<div className="space-y-1.5 max-h-72 overflow-y-auto pr-1">
{modelSegments.map((s, i) => (
<div key={s.model} className="flex items-center gap-3 text-sm">
<span className="inline-block h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: modelColor(i) }} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-content-primary" title={s.model}>{shortModel(s.model)}</span>
<span className="shrink-0 text-xs text-content-muted tabular-nums">
{fmtTokens(s.tokens)} <span className="text-content-muted">· {s.pct.toFixed(1)}%</span>
</span>
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-border">
<div className="h-full rounded-full transition-all" style={{ width: `${Math.max(2, s.pct)}%`, backgroundColor: modelColor(i) }} />
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
@@ -172,31 +191,16 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
);
}
function SummaryChip({
label,
value,
tone,
}: {
label: string;
value: string;
tone: "default" | "orange" | "blue" | "green";
}) {
function SummaryChip({ label, value, tone }: { label: string; value: string; tone: "default" | "orange" | "blue" | "green" }) {
const valueClass =
tone === "orange"
? "text-orange-600 dark:text-orange-400"
: tone === "blue"
? "text-blue-400"
: tone === "green"
? "text-emerald-600 dark:text-emerald-400"
: "text-content-primary";
tone === "orange" ? "text-orange-600 dark:text-orange-400"
: tone === "blue" ? "text-blue-400"
: tone === "green" ? "text-emerald-600 dark:text-emerald-400"
: "text-content-primary";
return (
<div className="rounded-md bg-hover px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-content-muted">
{label}
</div>
<div className={`mt-0.5 text-sm font-semibold tabular-nums ${valueClass}`}>
{value}
</div>
<div className="text-[10px] uppercase tracking-wider text-content-muted">{label}</div>
<div className={`mt-0.5 text-sm font-semibold tabular-nums ${valueClass}`}>{value}</div>
</div>
);
}
}
+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>
);
+5 -4
View File
@@ -548,7 +548,7 @@ export function SessionsPage() {
<div className="text-xs text-content-muted pb-2">
{data?.home && <>{data.home}</>}
<br />
会话管理功能基于{" "}
{t("sessionsAttribution")}{" "}
<button
type="button"
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
@@ -556,7 +556,7 @@ export function SessionsPage() {
>
kimicode-dashboard
</button>{" "}
(MIT,© JochenYang)移植
{t("attributionSuffix")}
</div>
{/* Confirm dialog */}
@@ -721,6 +721,7 @@ function PreviewMessageCard({
text: string;
locale: string;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const isLong = text.length > PREVIEW_TEXT_LIMIT;
const visible = expanded || !isLong ? text : text.slice(0, PREVIEW_TEXT_LIMIT);
@@ -754,8 +755,8 @@ function PreviewMessageCard({
className="mt-1 text-[11px] text-blue-400 hover:text-blue-300 hover:underline"
>
{expanded
? `收起 · ${text.length.toLocaleString()} 字符`
: `展开全文 · ${text.length.toLocaleString()} 字符`}
? t("previewCollapseChars", { n: text.length.toLocaleString() })
: t("previewExpandChars", { n: text.length.toLocaleString() })}
</button>
)}
</div>