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
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface UpdateInfo {
|
||||
@@ -10,7 +10,10 @@ export interface UpdateInfo {
|
||||
}
|
||||
|
||||
const THROTTLE_KEY = "kimi-switch-last-update-check";
|
||||
const THROTTLE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
// Both the throttle window and the periodic re-check run on an 8h cadence:
|
||||
// long-running sessions still pick up new releases without a restart, and the
|
||||
// actual backend call is still gated so short windows don't double-fire.
|
||||
const CHECK_INTERVAL_MS = 8 * 60 * 60 * 1000; // 8 hours
|
||||
|
||||
export function useUpdateCheck() {
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
@@ -26,7 +29,7 @@ export function useUpdateCheck() {
|
||||
|
||||
const doCheck = useCallback(async (force: boolean) => {
|
||||
if (!force) {
|
||||
if (lastChecked && Date.now() - lastChecked < THROTTLE_MS) return;
|
||||
if (lastChecked && Date.now() - lastChecked < CHECK_INTERVAL_MS) return;
|
||||
}
|
||||
setChecking(true);
|
||||
try {
|
||||
@@ -40,16 +43,26 @@ export function useUpdateCheck() {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// silently fail — user can retry from Settings
|
||||
// silently fail — network errors, parse errors, etc. should not nag
|
||||
// the user. They can still trigger a manual check from Settings.
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, [lastChecked]);
|
||||
|
||||
// On mount: throttled auto-check
|
||||
// Keep a ref to the latest doCheck so the periodic interval doesn't need
|
||||
// to be reset on every lastChecked change.
|
||||
const doCheckRef = useRef(doCheck);
|
||||
useEffect(() => {
|
||||
doCheck(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
doCheckRef.current = doCheck;
|
||||
}, [doCheck]);
|
||||
|
||||
// On mount: throttled auto-check + periodic re-check while the window
|
||||
// stays open. Errors are silent (handled inside doCheck).
|
||||
useEffect(() => {
|
||||
doCheckRef.current(false);
|
||||
const interval = setInterval(() => doCheckRef.current(false), CHECK_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const checkNow = useCallback(() => doCheck(true), [doCheck]);
|
||||
|
||||
@@ -209,6 +209,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
totalTokens: "Total tokens",
|
||||
cost: "Cost",
|
||||
modelDistribution: "Model distribution",
|
||||
providerDistribution: "Provider distribution",
|
||||
modalEscHint: "Press Esc or click outside to close",
|
||||
doubleClickHint: "Double-click for details",
|
||||
|
||||
@@ -252,4 +253,67 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
unknownModelProvider: "Model '{0}' references unknown provider '{1}'",
|
||||
emptyModelId: "Model '{0}' has an empty model ID",
|
||||
invalidMaxContextSize: "Model '{0}' has an invalid max_context_size",
|
||||
|
||||
// Dashboard — ranges / status
|
||||
rangeToday: "Today",
|
||||
range7d: "7d",
|
||||
range30d: "30d",
|
||||
rangeAll: "All",
|
||||
dashLoadFailed: "Failed to load",
|
||||
|
||||
// Dashboard — KPI labels
|
||||
kpiRequests: "Requests",
|
||||
kpiInputOther: "Non-cached input",
|
||||
kpiOutput: "Output",
|
||||
kpiCacheRead: "Cache read",
|
||||
kpiCacheCreation: "Cache creation",
|
||||
kpiCacheHit: "Cache hit",
|
||||
kpiCost: "Est. cost",
|
||||
|
||||
// Dashboard — cards
|
||||
cardHeatmap: "Full-year heatmap",
|
||||
cardHeatmapSub: "Coloured by token volume",
|
||||
cardDailyTrend: "Usage trend",
|
||||
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
|
||||
tabModelTrend: "Model usage trend",
|
||||
tabProviderTrend: "Provider–model usage trend",
|
||||
providerUnknown: "Unknown provider",
|
||||
cardDailyTrendSub: "{n} days",
|
||||
cardModelUsage: "Model usage",
|
||||
cardModelUsageSub: "{n} models",
|
||||
cardRecent: "Recent requests",
|
||||
cardRecentCapped: "Showing first {n} / {m}",
|
||||
cardRecentCount: "{n} entries",
|
||||
|
||||
// Dashboard — table columns / units
|
||||
colModel: "Model",
|
||||
colTokens: "Tokens",
|
||||
colCacheHit: "Cache hit",
|
||||
colTime: "Time",
|
||||
colInput: "Input",
|
||||
colOutput: "Output",
|
||||
colCacheRead: "Cache read",
|
||||
unitTimes: "requests",
|
||||
estimate: "est.",
|
||||
collapse: "Collapse",
|
||||
expandAll: "Expand all ({n})",
|
||||
|
||||
// Dashboard — pagination
|
||||
paginationPage: "Page {x} / {y}",
|
||||
paginationRange: "{x}–{y}",
|
||||
paginationPerPage: "{n} per page",
|
||||
pageFirst: "First",
|
||||
pagePrev: "Previous",
|
||||
pageNext: "Next",
|
||||
pageLast: "Last",
|
||||
|
||||
// Dashboard — footer / attribution
|
||||
footerScanned: "Scanned {n} files · {m} records",
|
||||
dashboardAttribution: "Dashboard ported from",
|
||||
sessionsAttribution: "Session manager ported from",
|
||||
attributionSuffix: "(MIT, © JochenYang)",
|
||||
|
||||
// Sessions — preview
|
||||
previewCollapseChars: "Collapse · {n} chars",
|
||||
previewExpandChars: "Expand · {n} chars",
|
||||
};
|
||||
@@ -204,6 +204,7 @@ export const zhTranslations = {
|
||||
totalTokens: "总 Token",
|
||||
cost: "费用",
|
||||
modelDistribution: "模型用量分布",
|
||||
providerDistribution: "供应商用量分布",
|
||||
modalEscHint: "按 Esc 或点击外部关闭",
|
||||
doubleClickHint: "双击查看详情",
|
||||
|
||||
@@ -247,6 +248,69 @@ export const zhTranslations = {
|
||||
unknownModelProvider: "模型「{0}」引用了未知供应商「{1}」",
|
||||
emptyModelId: "模型「{0}」的模型 ID 为空",
|
||||
invalidMaxContextSize: "模型「{0}」的 max_context_size 无效",
|
||||
|
||||
// Dashboard — ranges / status
|
||||
rangeToday: "今天",
|
||||
range7d: "7 天",
|
||||
range30d: "30 天",
|
||||
rangeAll: "全部",
|
||||
dashLoadFailed: "加载失败",
|
||||
|
||||
// Dashboard — KPI labels
|
||||
kpiRequests: "请求数",
|
||||
kpiInputOther: "非缓存输入",
|
||||
kpiOutput: "输出",
|
||||
kpiCacheRead: "缓存读",
|
||||
kpiCacheCreation: "缓存创建",
|
||||
kpiCacheHit: "缓存命中",
|
||||
kpiCost: "预估费用",
|
||||
|
||||
// Dashboard — cards
|
||||
cardHeatmap: "全年热力图",
|
||||
cardHeatmapSub: "按 Token 量着色",
|
||||
cardDailyTrend: "用量趋势",
|
||||
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
|
||||
tabModelTrend: "模型用量趋势",
|
||||
tabProviderTrend: "供应商模型用量趋势",
|
||||
providerUnknown: "未知供应商",
|
||||
cardDailyTrendSub: "共 {n} 天",
|
||||
cardModelUsage: "模型用量",
|
||||
cardModelUsageSub: "{n} 个模型",
|
||||
cardRecent: "最近请求",
|
||||
cardRecentCapped: "显示前 {n} / {m} 条",
|
||||
cardRecentCount: "{n} 条",
|
||||
|
||||
// Dashboard — table columns / units
|
||||
colModel: "模型",
|
||||
colTokens: "Token",
|
||||
colCacheHit: "缓存命中",
|
||||
colTime: "时间",
|
||||
colInput: "输入",
|
||||
colOutput: "输出",
|
||||
colCacheRead: "缓存读",
|
||||
unitTimes: "次",
|
||||
estimate: "估算",
|
||||
collapse: "收起",
|
||||
expandAll: "展开全部 ({n})",
|
||||
|
||||
// Dashboard — pagination
|
||||
paginationPage: "第 {x} / {y} 页",
|
||||
paginationRange: "{x}–{y} 条",
|
||||
paginationPerPage: "每页 {n} 条",
|
||||
pageFirst: "首页",
|
||||
pagePrev: "上一页",
|
||||
pageNext: "下一页",
|
||||
pageLast: "末页",
|
||||
|
||||
// Dashboard — footer / attribution
|
||||
footerScanned: "扫描 {n} 个文件 · {m} 条记录",
|
||||
dashboardAttribution: "仪表盘功能基于",
|
||||
sessionsAttribution: "会话管理功能基于",
|
||||
attributionSuffix: "(MIT,© JochenYang)移植",
|
||||
|
||||
// Sessions — preview
|
||||
previewCollapseChars: "收起 · {n} 字符",
|
||||
previewExpandChars: "展开全文 · {n} 字符",
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof zhTranslations;
|
||||
@@ -14,6 +14,8 @@ export interface TotalsRow {
|
||||
export interface DailyRow extends TotalsRow {
|
||||
date: string;
|
||||
byModel: Record<string, number>;
|
||||
byProvider: Record<string, number>;
|
||||
byProviderModel: Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
export interface ModelRow extends TotalsRow {
|
||||
|
||||
Reference in new issue
Block a user