v0.6.2: 仪表盘布局重构 + 每日趋势折线图 + OpenCode Go/Zen 预设

仪表盘:
- 新增每日用量趋势折线图(Token/缓存命中率/请求数三线)
- 用量趋势 Tab 组(每日/模型/供应商模型)与模型表格同行等高
- 热力图独立整行,格子放大 16px
- 折线图坐标轴文字改 HTML 覆盖层(修复 SVG 拉伸变形)
- 表格 border-collapse + 行线与边框同色
- 模型表格估算标签行内显示(行高统一)
- 最近请求容器 max-h 限制
- 页脚归属放内容流末尾

模型唯一性:
- 获取模型列表时自动勾选已存在模型 + 已添加徽章
- 批量添加跳过同供应商已存在 model id
- onBulkAdd 同供应商 model id 去重

新增预设:
- OpenCode Go(套餐 subscription,带推荐链接)
- OpenCode Zen(按量 pay_as_you_go)

折线图优化:
- 缓存命中率 0-1 映射修复
- 请求数默认显示
- 直线连接 + 渐变填充
This commit is contained in:
KimiSwitch Dev committed 2026-07-30 11:50:05 +08:00
1 parent 2614f80d0f
commit bee981aefa
13 files changed
+739 -95

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "kimiswitch", "name": "kimiswitch",
"private": true, "private": true,
"version": "0.6.0", "version": "0.6.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1958,7 +1958,7 @@ dependencies = [
[[package]] [[package]]
name = "kimiswitch" name = "kimiswitch"
version = "0.6.0" version = "0.6.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.6.0" version = "0.6.2"
description = "Kimi Switch - model config manager" description = "Kimi Switch - model config manager"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"productName": "Kimi Switch", "productName": "Kimi Switch",
"version": "0.6.0", "version": "0.6.2",
"identifier": "com.kimiswitch.app", "identifier": "com.kimiswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+5
View File
@@ -703,6 +703,11 @@ export default function App() {
updateConfig((cfg) => { updateConfig((cfg) => {
const updated = { ...cfg.models }; const updated = { ...cfg.models };
for (const m of models) { for (const m of models) {
// Skip if this model id already exists for the same provider
const dup = Object.values(updated).some(
(mm) => mm.provider === m.provider && mm.model === m.model && m.model !== ""
);
if (dup) continue;
let alias = m.alias; let alias = m.alias;
let n = 1; let n = 1;
while (alias in updated) { while (alias in updated) {
+109 -39
View File
@@ -2,7 +2,12 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh"; import type { TranslationKey } from "../i18n/zh";
import { providerPresets, type PresetCategory, type ProviderPreset } from "../config/providerPresets"; import {
providerPresets,
type BillingMode,
type PresetCategory,
type ProviderPreset,
} from "../config/providerPresets";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
interface PresetPickerModalProps { interface PresetPickerModalProps {
@@ -26,12 +31,26 @@ const CATEGORY_LABEL: Record<Exclude<PresetCategory, "custom">, TranslationKey>
aggregator: "presetCategoryAggregator", aggregator: "presetCategoryAggregator",
}; };
const BILLING_BADGE: Record<BillingMode, string> = {
subscription: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-400",
pay_as_you_go: "bg-slate-200 text-slate-600 dark:bg-slate-500/15 dark:text-slate-400",
};
const BILLING_LABEL: Record<BillingMode, TranslationKey> = {
subscription: "billingBadgeSubscription",
pay_as_you_go: "billingBadgePayAsYouGo",
};
type SortMode = "original" | "alpha"; type SortMode = "original" | "alpha";
type BillingTab = "all" | BillingMode;
const BILLING_TAB_ORDER: BillingTab[] = ["subscription", "pay_as_you_go", "all"];
export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) { export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("original"); const [sort, setSort] = useState<SortMode>("original");
const [billingTab, setBillingTab] = useState<BillingTab>("all");
// Esc to close // Esc to close
useEffect(() => { useEffect(() => {
@@ -58,15 +77,31 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
if (open) { if (open) {
setQuery(""); setQuery("");
setSort("original"); setSort("original");
setBillingTab("all");
} }
}, [open]); }, [open]);
// Counts per billing tab — shown in the tab labels so users see how many
// presets are in each bucket without having to click through.
const billingCounts = useMemo(() => {
let sub = 0;
let payg = 0;
for (const p of providerPresets) {
if (p.billingMode === "subscription") sub++;
else payg++;
}
return { subscription: sub, pay_as_you_go: payg, all: sub + payg };
}, []);
const presetName = (p: ProviderPreset) => const presetName = (p: ProviderPreset) =>
p.nameKey ? t(p.nameKey as TranslationKey) : p.name; p.nameKey ? t(p.nameKey as TranslationKey) : p.name;
const visible = useMemo(() => { const visible = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
let list = providerPresets; let list = providerPresets;
if (billingTab !== "all") {
list = list.filter((p) => p.billingMode === billingTab);
}
if (q) { if (q) {
list = list.filter( list = list.filter(
(p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q) (p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q)
@@ -77,7 +112,7 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
} }
return list; return list;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, sort, t]); }, [query, sort, billingTab, t]);
if (!open) return null; if (!open) return null;
@@ -110,39 +145,67 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
</button> </button>
</div> </div>
{/* Search + sort */} {/* Billing-mode tabs + search + sort */}
<div className="flex items-center gap-2 border-b border-border px-5 py-3"> <div className="flex flex-col gap-2 border-b border-border px-5 py-3">
<input <div className="flex items-center bg-input border border-border rounded p-0.5 self-start">
type="text" {BILLING_TAB_ORDER.map((tab) => {
value={query} const labelKey: TranslationKey =
onChange={(e) => setQuery(e.target.value)} tab === "all"
placeholder={t("presetSearchPlaceholder")} ? "billingTabAll"
autoFocus : tab === "subscription"
className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500" ? "billingTabSubscription"
/> : "billingTabPayAsYouGo";
<div className="flex items-center rounded border border-border bg-input p-0.5"> const count = billingCounts[tab];
<button const active = billingTab === tab;
type="button" return (
onClick={() => setSort("original")} <button
className={`px-2 py-1 text-xs rounded transition-colors ${ key={tab}
sort === "original" type="button"
? "bg-blue-600 text-white" onClick={() => setBillingTab(tab)}
: "text-content-muted hover:text-content-primary" className={`px-2.5 py-1 text-xs rounded transition-colors ${
}`} active
> ? "bg-blue-600 text-white"
{t("presetSortOriginal")} : "text-content-muted hover:text-content-primary"
</button> }`}
<button >
type="button" {t(labelKey)} ({count})
onClick={() => setSort("alpha")} </button>
className={`px-2 py-1 text-xs rounded transition-colors ${ );
sort === "alpha" })}
? "bg-blue-600 text-white" </div>
: "text-content-muted hover:text-content-primary" <div className="flex items-center gap-2">
}`} <input
> type="text"
{t("presetSortAlpha")} value={query}
</button> onChange={(e) => setQuery(e.target.value)}
placeholder={t("presetSearchPlaceholder")}
autoFocus
className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="flex items-center rounded border border-border bg-input p-0.5">
<button
type="button"
onClick={() => setSort("original")}
className={`px-2 py-1 text-xs rounded transition-colors ${
sort === "original"
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{t("presetSortOriginal")}
</button>
<button
type="button"
onClick={() => setSort("alpha")}
className={`px-2 py-1 text-xs rounded transition-colors ${
sort === "alpha"
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{t("presetSortAlpha")}
</button>
</div>
</div> </div>
</div> </div>
@@ -168,13 +231,20 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
<div className="w-full truncate text-sm font-medium text-content-primary"> <div className="w-full truncate text-sm font-medium text-content-primary">
{presetName(p)} {presetName(p)}
</div> </div>
{p.category !== "custom" && ( <div className="flex flex-wrap gap-1">
{p.category !== "custom" && (
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_BADGE[p.category]}`}
>
{t(CATEGORY_LABEL[p.category])}
</span>
)}
<span <span
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_BADGE[p.category]}`} className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${BILLING_BADGE[p.billingMode]}`}
> >
{t(CATEGORY_LABEL[p.category])} {t(BILLING_LABEL[p.billingMode])}
</span> </span>
)} </div>
</button> </button>
))} ))}
</div> </div>
+13 -2
View File
@@ -459,6 +459,9 @@ function ModelMapping({
provider, provider,
}); });
setDiscovered(result); setDiscovered(result);
// Auto-select models that already exist in the current provider's list
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
setSelected(new Set(result.filter((dm) => existingIds.has(dm.id)).map((dm) => dm.id)));
} catch (err) { } catch (err) {
setDiscoverError(err instanceof Error ? err.message : String(err)); setDiscoverError(err instanceof Error ? err.message : String(err));
} finally { } finally {
@@ -477,9 +480,11 @@ function ModelMapping({
const handleAddSelected = () => { const handleAddSelected = () => {
if (!discovered || selected.size === 0) return; if (!discovered || selected.size === 0) return;
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
const toAdd: Model[] = []; const toAdd: Model[] = [];
for (const dm of discovered) { for (const dm of discovered) {
if (!selected.has(dm.id)) continue; if (!selected.has(dm.id)) continue;
if (existingIds.has(dm.id)) continue; // skip models already in this provider
const safeProvider = provider.name.replace(/\//g, "-"); const safeProvider = provider.name.replace(/\//g, "-");
const safeModelId = dm.id.replace(/\//g, "-"); const safeModelId = dm.id.replace(/\//g, "-");
const alias = `${safeProvider}/${safeModelId}`; const alias = `${safeProvider}/${safeModelId}`;
@@ -555,7 +560,9 @@ function ModelMapping({
{t("fetchEnableThinking")} {t("fetchEnableThinking")}
</label> </label>
<div className="max-h-48 overflow-auto space-y-1"> <div className="max-h-48 overflow-auto space-y-1">
{discovered.map((m) => ( {discovered.map((m) => {
const exists = models.some((mm) => mm.model === m.id);
return (
<label <label
key={m.id} key={m.id}
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-input p-1.5 rounded" className="flex items-center gap-2 text-sm cursor-pointer hover:bg-input p-1.5 rounded"
@@ -569,8 +576,12 @@ function ModelMapping({
{m.display_name && ( {m.display_name && (
<span className="text-content-muted">({m.display_name})</span> <span className="text-content-muted">({m.display_name})</span>
)} )}
{exists && (
<span className="ml-auto text-[10px] text-emerald-500 shrink-0">{t("alreadyAdded")}</span>
)}
</label> </label>
))} );
})}
</div> </div>
<button <button
type="button" type="button"
+64 -39
View File
@@ -5,6 +5,7 @@ import { useTranslation } from "../../i18n";
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format"; import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import { DailyBars } from "./DailyBars"; import { DailyBars } from "./DailyBars";
import { Heatmap } from "./Heatmap"; import { Heatmap } from "./Heatmap";
import { TrendLineChart } from "./TrendLineChart";
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"]; const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
@@ -12,18 +13,20 @@ function Card({
title, title,
subtitle, subtitle,
children, children,
className,
}: { }: {
title: string; title: string;
subtitle?: string; subtitle?: string;
children: ReactNode; children: ReactNode;
className?: string;
}) { }) {
return ( return (
<div className="flex flex-col rounded-xl border border-border bg-panel"> <div className={`flex flex-col rounded-xl border border-border bg-panel ${className ?? ""}`}>
<div className="border-b border-border px-4 py-2.5 flex items-baseline justify-between"> <div className="border-b border-border px-4 py-2.5 flex items-baseline justify-between">
<h3 className="text-sm font-medium text-content-primary">{title}</h3> <h3 className="text-sm font-medium text-content-primary">{title}</h3>
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>} {subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
</div> </div>
<div className="flex-1 p-4">{children}</div> <div className="flex flex-1 flex-col p-4">{children}</div>
</div> </div>
); );
} }
@@ -45,7 +48,7 @@ export function DashboardPage() {
const { range, changeRange, data, loading, error, refresh } = useDashboard(); const { range, changeRange, data, loading, error, refresh } = useDashboard();
const [showAllModels, setShowAllModels] = useState(false); const [showAllModels, setShowAllModels] = useState(false);
const [recentPage, setRecentPage] = useState(1); const [recentPage, setRecentPage] = useState(1);
const [trendTab, setTrendTab] = useState<"model" | "provider">("model"); const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
const RANGE_LABELS: Record<DashboardRange, string> = { const RANGE_LABELS: Record<DashboardRange, string> = {
today: t("rangeToday"), today: t("rangeToday"),
@@ -187,18 +190,29 @@ export function DashboardPage() {
))} ))}
</div> </div>
{/* Heatmap — full-year strip, fixed-size cells, centered */} {/* Heatmap — full row */}
<Card title={t("cardHeatmap")} subtitle={t("cardHeatmapSub")}> <Card title={t("cardHeatmap")} subtitle={t("cardHeatmapSub")}>
<Heatmap heatmap={heatmap} /> <Heatmap heatmap={heatmap} />
</Card> </Card>
{/* Charts + Model side table */} {/* Usage-trend tabs + Model table side by side */}
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]"> <div className="grid items-stretch gap-4 lg:grid-cols-[1.2fr_1fr]">
{/* Usage-trend tab container */} {/* Usage-trend tab container */}
<div className="flex flex-col rounded-xl border border-border bg-panel"> <div className="flex flex-col rounded-xl border border-border bg-panel">
{/* Tab bar header */} {/* Tab bar header */}
<div className="flex items-end justify-between border-b border-border px-2"> <div className="flex items-end justify-between border-b border-border px-2">
<div className="flex gap-1"> <div className="flex gap-1">
<button
type="button"
onClick={() => setTrendTab("daily")}
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
trendTab === "daily"
? "border-blue-500 text-content-primary"
: "border-transparent text-content-muted hover:text-content-primary"
}`}
>
{t("tabDailyTrend")}
</button>
<button <button
type="button" type="button"
onClick={() => setTrendTab("model")} onClick={() => setTrendTab("model")}
@@ -223,21 +237,29 @@ export function DashboardPage() {
</button> </button>
</div> </div>
<span className="pb-2 text-xs text-content-muted"> <span className="pb-2 text-xs text-content-muted">
{daily.length > 30 {trendTab === "daily"
? t("cardDailyTrendSubCapped", { n: daily.length }) ? t("trendLineSub", { n: trendDaily.length })
: t("cardDailyTrendSub", { n: daily.length })} : daily.length > 30
? t("cardDailyTrendSubCapped", { n: daily.length })
: t("cardDailyTrendSub", { n: daily.length })}
</span> </span>
</div> </div>
{/* Active tab content */} {/* Active tab content */}
<div className="flex-1 p-4"> <div className="flex flex-1 flex-col p-4">
<div className="h-full min-h-[240px]"> {trendTab === "daily" ? (
<DailyBars <div className="flex-1 min-h-[240px]">
daily={trendDaily} <TrendLineChart daily={trendDaily} />
dimension={trendTab} </div>
names={trendTab === "model" ? modelNames : providerNames} ) : (
unknownProviderLabel={t("providerUnknown")} <div className="flex-1 min-h-[240px]">
/> <DailyBars
</div> daily={trendDaily}
dimension={trendTab}
names={trendTab === "model" ? modelNames : providerNames}
unknownProviderLabel={t("providerUnknown")}
/>
</div>
)}
</div> </div>
</div> </div>
@@ -246,7 +268,7 @@ export function DashboardPage() {
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div> <div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
) : ( ) : (
<div className="max-h-[360px] overflow-auto"> <div className="max-h-[360px] overflow-auto">
<table className="w-full text-sm"> <table className="w-full text-sm border-collapse">
<thead className="sticky top-0 bg-panel"> <thead className="sticky top-0 bg-panel">
<tr className="text-left text-xs text-content-muted border-b border-border"> <tr className="text-left text-xs text-content-muted border-b border-border">
<th className="pb-2 pr-3 font-normal">{t("colModel")}</th> <th className="pb-2 pr-3 font-normal">{t("colModel")}</th>
@@ -260,7 +282,7 @@ export function DashboardPage() {
{visibleModels.map((m) => ( {visibleModels.map((m) => (
<tr <tr
key={m.model} key={m.model}
className="border-b border-border/50 hover:bg-hover" className="border-b border-border hover:bg-hover"
> >
<td className="py-2 pr-3"> <td className="py-2 pr-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -273,10 +295,10 @@ export function DashboardPage() {
: "#6b7280", : "#6b7280",
}} }}
/> />
<div className="min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<div className="text-content-primary whitespace-nowrap">{m.modelDisplay || m.model}</div> <span className="text-content-primary whitespace-nowrap truncate">{m.modelDisplay || m.model}</span>
{m.costEstimated && ( {m.costEstimated && (
<span className="text-[10px] text-yellow-600">{t("estimate")}</span> <span className="text-[10px] text-yellow-600 shrink-0">({t("estimate")})</span>
)} )}
</div> </div>
</div> </div>
@@ -316,8 +338,8 @@ export function DashboardPage() {
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div> <div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
) : ( ) : (
<> <>
<div className="overflow-auto"> <div className="overflow-auto max-h-[860px]">
<table className="w-full text-sm"> <table className="w-full text-sm border-collapse">
<thead className="sticky top-0 bg-panel"> <thead className="sticky top-0 bg-panel">
<tr className="text-left text-xs text-content-muted border-b border-border"> <tr className="text-left text-xs text-content-muted border-b border-border">
<th className="pb-2 pr-4 font-normal">{t("colTime")}</th> <th className="pb-2 pr-4 font-normal">{t("colTime")}</th>
@@ -330,7 +352,7 @@ export function DashboardPage() {
</thead> </thead>
<tbody> <tbody>
{recentPageRows.map((r, i) => ( {recentPageRows.map((r, i) => (
<tr key={recentStart + i} className="border-b border-border/50 hover:bg-hover"> <tr key={recentStart + i} className="border-b border-border hover:bg-hover">
<td className="py-1.5 pr-4 text-content-muted whitespace-nowrap"> <td className="py-1.5 pr-4 text-content-muted whitespace-nowrap">
{fmtTime(r.time)} {fmtTime(r.time)}
</td> </td>
@@ -398,20 +420,23 @@ export function DashboardPage() {
)} )}
</Card> </Card>
{/* Attribution footer */} {/* Footer — meta + attribution at the end of the content flow */}
<div className="text-xs text-content-muted pb-2"> <div className="text-xs text-content-muted pb-2">
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "} <div>
{data.meta.home} {t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
<br /> {data.meta.home}
{t("dashboardAttribution")}{" "} </div>
<button <div className="mt-0.5">
type="button" {t("dashboardAttribution")}{" "}
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")} <button
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer" type="button"
> onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
kimicode-dashboard className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
</button>{" "} >
{t("attributionSuffix")} kimicode-dashboard
</button>{" "}
{t("attributionSuffix")}
</div>
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -19,7 +19,7 @@ const LEVEL_BG = [
const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"]; const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"];
const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const CELL = 12; // px const CELL = 16; // px
const GAP = 3; // px const GAP = 3; // px
const LABEL_W = 28; // dow label column width const LABEL_W = 28; // dow label column width
+369
View File
@@ -0,0 +1,369 @@
import { useMemo, useState, type MouseEvent } from "react";
import { useTranslation } from "../../i18n";
import { fmtTokens } from "../../lib/dashboard-format";
import type { DailyRow } from "../../types/dashboard";
interface TrendLineChartProps {
/** Per-day totals, already capped to the desired window by the caller. */
daily: DailyRow[];
}
const W = 800;
const H = 130;
const PAD_L = 56;
const PAD_R = 56;
const PAD_T = 10;
const PAD_B = 22;
const PLOT_W = W - PAD_L - PAD_R;
const PLOT_H = H - PAD_T - PAD_B;
const COLOR_TOKENS = "#3b82f6"; // blue
const COLOR_CACHE = "#22c55e"; // green
const COLOR_REQUESTS = "#f97316"; // orange
/** Human-friendly token count for axis labels (K / M / B). */
function fmtTokenAxis(value: number): string {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`;
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`;
return String(Math.round(value));
}
function fmtInt(n: number): string {
return Math.round(n).toLocaleString();
}
function niceMax(value: number): number {
if (value <= 0) return 1;
const exp = Math.floor(Math.log10(value));
const base = Math.pow(10, exp);
const norm = value / base;
let nice: number;
if (norm <= 1) nice = 1;
else if (norm <= 2) nice = 2;
else if (norm <= 5) nice = 5;
else nice = 10;
return nice * base;
}
export function TrendLineChart({ daily }: TrendLineChartProps) {
const { t } = useTranslation();
const [showRequests, setShowRequests] = useState(true);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const maxTokens = useMemo(() => {
if (!daily?.length) return 1;
return niceMax(Math.max(...daily.map((d) => d.totalTokens || 0), 1));
}, [daily]);
const maxRequests = useMemo(() => {
if (!daily?.length) return 1;
return niceMax(Math.max(...daily.map((d) => d.requests || 0), 1));
}, [daily]);
const n = daily?.length ?? 0;
const stepX = n > 1 ? PLOT_W / (n - 1) : PLOT_W;
const pointX = (i: number) => (n > 1 ? PAD_L + i * stepX : PAD_L + PLOT_W / 2);
const pointYTokens = (v: number) => PAD_T + PLOT_H - (v / maxTokens) * PLOT_H;
// Cache hit rate is a fraction 0-1 (see fmtPct); map onto the right 0-100% axis.
const pointYCache = (v: number) => PAD_T + PLOT_H - Math.max(0, Math.min(1, v)) * PLOT_H;
const pointYReq = (v: number) => PAD_T + PLOT_H - (v / maxRequests) * PLOT_H;
const pathTokens = useMemo(() => {
if (!daily?.length) return "";
return daily
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYTokens(d.totalTokens || 0)}`)
.join(" ");
}, [daily, maxTokens]);
const pathCache = useMemo(() => {
if (!daily?.length) return "";
return daily
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYCache(d.cacheHitRate || 0)}`)
.join(" ");
}, [daily]);
const pathRequests = useMemo(() => {
if (!daily?.length) return "";
return daily
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYReq(d.requests || 0)}`)
.join(" ");
}, [daily, maxRequests]);
const areaTokens = useMemo(() => {
if (!daily?.length) return "";
const lastX = pointX(daily.length - 1);
return (
pathTokens +
` L ${lastX} ${PAD_T + PLOT_H} L ${PAD_L} ${PAD_T + PLOT_H} Z`
);
}, [pathTokens, daily]);
// X-axis ticks: show every ~5 days (plus the first and last) to avoid overlap.
const xTicks = useMemo(() => {
if (!daily?.length) return [];
if (n <= 6) return daily.map((_, i) => i);
const step = Math.max(1, Math.round(n / 6));
const set = new Set<number>([0, n - 1]);
for (let i = step; i < n - 1; i += step) set.add(i);
return Array.from(set).sort((a, b) => a - b);
}, [daily, n]);
// Hover handling
const onMove = (e: MouseEvent<SVGSVGElement>) => {
if (!daily?.length) return;
const rect = e.currentTarget.getBoundingClientRect();
const scale = W / rect.width;
const x = (e.clientX - rect.left) * scale - PAD_L;
if (x < 0 || x > PLOT_W) {
setHoverIndex(null);
return;
}
const idx = Math.max(0, Math.min(n - 1, Math.round(x / stepX)));
setHoverIndex(idx);
};
const onLeave = () => setHoverIndex(null);
if (!daily?.length) {
return (
<div className="flex h-32 items-center justify-center text-sm text-content-muted">
{t("trendLineNoData")}
</div>
);
}
const hover = hoverIndex != null ? daily[hoverIndex] : null;
return (
<div className="flex h-full flex-col gap-2">
{/* Legend + toggle */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-content-muted">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_TOKENS }} />
{t("trendLineLegendTokens")}
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_CACHE }} />
{t("trendLineLegendCacheHit")}
</span>
{showRequests && (
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_REQUESTS }} />
{t("trendLineLegendRequests")}
</span>
)}
</div>
<label className="flex items-center gap-1.5 text-[11px] text-content-muted cursor-pointer select-none">
<input
type="checkbox"
checked={showRequests}
onChange={(e) => setShowRequests(e.target.checked)}
className="h-3 w-3 accent-blue-500"
/>
{t("trendLineShowRequests")}
</label>
</div>
{/* Chart */}
<div className="relative flex-1 min-h-0">
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full h-full"
preserveAspectRatio="none"
onMouseMove={onMove}
onMouseLeave={onLeave}
>
{/* Gradient definitions */}
<defs>
<linearGradient id="tokenAreaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={COLOR_TOKENS} stopOpacity="0.28" />
<stop offset="100%" stopColor={COLOR_TOKENS} stopOpacity="0.02" />
</linearGradient>
</defs>
{/* Y-axis grid lines only (labels rendered as HTML overlay below) */}
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
const y = PAD_T + PLOT_H * (1 - p);
return (
<line
key={p}
x1={PAD_L}
y1={y}
x2={W - PAD_R}
y2={y}
stroke="currentColor"
className="text-border"
strokeWidth={1}
strokeDasharray={p === 0 ? "" : "2 3"}
opacity={p === 0 ? 0.9 : 0.4}
/>
);
})}
{/* Area fill for tokens */}
<path d={areaTokens} fill="url(#tokenAreaGrad)" />
{/* Tokens line (area + line) */}
<path
d={pathTokens}
fill="none"
stroke={COLOR_TOKENS}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Cache hit line */}
<path
d={pathCache}
fill="none"
stroke={COLOR_CACHE}
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Requests line (optional) */}
{showRequests && (
<path
d={pathRequests}
fill="none"
stroke={COLOR_REQUESTS}
strokeWidth={1.25}
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{/* Hover crosshair + dot */}
{hover && (
<g pointerEvents="none">
<line
x1={pointX(hoverIndex!)}
y1={PAD_T}
x2={pointX(hoverIndex!)}
y2={PAD_T + PLOT_H}
stroke="currentColor"
className="text-content-muted"
strokeWidth={1}
strokeDasharray="2 2"
opacity={0.5}
/>
<circle
cx={pointX(hoverIndex!)}
cy={pointYTokens(hover.totalTokens || 0)}
r={3.5}
fill={COLOR_TOKENS}
stroke="currentColor"
className="text-panel"
strokeWidth={1.5}
/>
<circle
cx={pointX(hoverIndex!)}
cy={pointYCache(hover.cacheHitRate || 0)}
r={3}
fill={COLOR_CACHE}
stroke="currentColor"
className="text-panel"
strokeWidth={1.5}
/>
{showRequests && (
<circle
cx={pointX(hoverIndex!)}
cy={pointYReq(hover.requests || 0)}
r={2.5}
fill={COLOR_REQUESTS}
stroke="currentColor"
className="text-panel"
strokeWidth={1.5}
/>
)}
</g>
)}
</svg>
{/* Axis labels as HTML overlay (avoids SVG preserveAspectRatio="none" font distortion) */}
{/* Left Y-axis (tokens) */}
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100;
return (
<span
key={`yl-${p}`}
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
style={{
left: 0,
top: `${top}%`,
width: `${((PAD_L - 4) / W) * 100}%`,
textAlign: "right",
transform: "translateY(-50%)",
}}
>
{fmtTokenAxis(maxTokens * p)}
</span>
);
})}
{/* Right Y-axis (cache hit %) */}
{[0, 0.5, 1].map((p) => {
const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100;
return (
<span
key={`yr-${p}`}
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
style={{
right: 0,
top: `${top}%`,
width: `${((PAD_R - 4) / W) * 100}%`,
textAlign: "left",
transform: "translateY(-50%)",
}}
>
{Math.round(p * 100)}%
</span>
);
})}
{/* X-axis labels */}
{xTicks.map((i) => (
<span
key={`x-${i}`}
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
style={{
left: `${(pointX(i) / W) * 100}%`,
bottom: 0,
transform: "translateX(-50%)",
}}
>
{daily[i].date.slice(5)}
</span>
))}
{/* Tooltip (HTML overlay for readability) */}
{hover && (
<div
className="pointer-events-none absolute z-10 rounded-md border border-border bg-panel px-2.5 py-1.5 text-[11px] text-content-primary shadow-xl"
style={{
left: `${(pointX(hoverIndex!) / W) * 100}%`,
top: 4,
transform: hoverIndex! > n / 2 ? "translateX(-100%)" : "translateX(8px)",
}}
>
<div className="font-medium">{hover.date}</div>
<div className="text-content-muted">
{t("trendLineLegendTokens")}: <span className="tabular-nums">{fmtTokens(hover.totalTokens)}</span>
</div>
<div className="text-content-muted">
{t("trendLineLegendCacheHit")}: <span className="tabular-nums">{(hover.cacheHitRate * 100).toFixed(1)}%</span>
</div>
{showRequests && (
<div className="text-content-muted">
{t("trendLineLegendRequests")}: <span className="tabular-nums">{fmtInt(hover.requests)}</span>
</div>
)}
</div>
)}
</div>
</div>
);
}
+122 -6
View File
@@ -17,6 +17,9 @@ export type UsageKind =
| "plan:zhipu" | "plan:zhipu"
| "plan:minimax"; | "plan:minimax";
/** Billing model for a preset. Drives the tab filter in PresetPickerModal. */
export type BillingMode = "subscription" | "pay_as_you_go";
/** Runtime set used to catch TS/Rust enum drift (see dev-assert below). */ /** Runtime set used to catch TS/Rust enum drift (see dev-assert below). */
export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
"balance:deepseek", "balance:deepseek",
@@ -71,6 +74,12 @@ export interface ProviderPreset {
/** Defaults to ["thinking"]. */ /** Defaults to ["thinking"]. */
capabilities?: string[]; capabilities?: string[];
}>; }>;
/**
* Billing model — drives the 套餐/按量 tab in PresetPickerModal.
* - "subscription": coding-plan / token-plan style subscription (quota-based)
* - "pay_as_you_go": per-token API billing
*/
billingMode: BillingMode;
/** /**
* Billing/usage query kinds; omitted means "do not query". * Billing/usage query kinds; omitted means "do not query".
* Persisted to SQLite settings by the Rust side on save, never to * Persisted to SQLite settings by the Rust side on save, never to
@@ -95,6 +104,7 @@ export const providerPresets: ProviderPreset[] = [
{ model: "claude-sonnet-4-5" }, { model: "claude-sonnet-4-5" },
{ model: "claude-haiku-4-5" }, { model: "claude-haiku-4-5" },
], ],
billingMode: "pay_as_you_go",
}, },
{ {
id: "kimi-coding", id: "kimi-coding",
@@ -111,6 +121,7 @@ export const providerPresets: ProviderPreset[] = [
{ model: "kimi-for-coding", displayName: "Kimi For Coding" }, { model: "kimi-for-coding", displayName: "Kimi For Coding" },
{ model: "kimi-k2.7-code" }, { model: "kimi-k2.7-code" },
], ],
billingMode: "subscription",
usageKinds: ["plan:kimi_coding"], usageKinds: ["plan:kimi_coding"],
}, },
{ {
@@ -127,6 +138,7 @@ export const providerPresets: ProviderPreset[] = [
{ model: "kimi-k2-thinking-turbo" }, { model: "kimi-k2-thinking-turbo" },
{ model: "kimi-k2.6" }, { model: "kimi-k2.6" },
], ],
billingMode: "pay_as_you_go",
}, },
{ {
id: "deepseek", id: "deepseek",
@@ -138,12 +150,13 @@ export const providerPresets: ProviderPreset[] = [
baseUrl: "https://api.deepseek.com/v1", baseUrl: "https://api.deepseek.com/v1",
icon: "deepseek", icon: "deepseek",
models: [{ model: "deepseek-chat" }, { model: "deepseek-reasoner" }], models: [{ model: "deepseek-chat" }, { model: "deepseek-reasoner" }],
billingMode: "pay_as_you_go",
usageKinds: ["balance:deepseek"], usageKinds: ["balance:deepseek"],
}, },
{ {
id: "zhipu", id: "zhipu-api",
name: "Zhipu GLM", name: "Zhipu GLM (API)",
nameKey: "presetNameZhipu", nameKey: "presetNameZhipuApi",
websiteUrl: "https://open.bigmodel.cn", websiteUrl: "https://open.bigmodel.cn",
category: "cn_official", category: "cn_official",
providerType: "openai", providerType: "openai",
@@ -154,18 +167,51 @@ export const providerPresets: ProviderPreset[] = [
{ model: "glm-4.6" }, { model: "glm-4.6" },
{ model: "glm-4.5-air" }, { model: "glm-4.5-air" },
], ],
billingMode: "pay_as_you_go",
},
{
id: "zhipu-coding",
name: "Zhipu GLM Coding Plan",
nameKey: "presetNameZhipuCoding",
websiteUrl: "https://open.bigmodel.cn",
category: "cn_official",
providerType: "openai",
// Coding Plan endpoint is separate from the pay-as-you-go PaaS API.
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
icon: "zhipu",
models: [
{ model: "glm-4.7" },
{ model: "glm-4.6" },
{ model: "glm-4.5-air" },
],
billingMode: "subscription",
usageKinds: ["plan:zhipu"], usageKinds: ["plan:zhipu"],
}, },
{ {
id: "zai", id: "zai-api",
name: "z.ai", name: "z.ai (API)",
nameKey: "presetNameZai", nameKey: "presetNameZaiApi",
websiteUrl: "https://z.ai", websiteUrl: "https://z.ai",
category: "third_party", category: "third_party",
providerType: "openai", providerType: "openai",
baseUrl: "https://api.z.ai/api/paas/v4", baseUrl: "https://api.z.ai/api/paas/v4",
icon: "zhipu", icon: "zhipu",
models: [{ model: "glm-4.7" }, { model: "glm-4.6" }], models: [{ model: "glm-4.7" }, { model: "glm-4.6" }],
billingMode: "pay_as_you_go",
},
{
id: "zai-coding",
name: "z.ai Coding Plan",
nameKey: "presetNameZaiCoding",
websiteUrl: "https://z.ai",
category: "third_party",
providerType: "openai",
// Coding Plan endpoint (mirror of Zhipu GLM Coding Plan). If the path
// differs on z.ai, fall back to a known mirror.
baseUrl: "https://api.z.ai/api/coding/paas/v4",
icon: "zhipu",
models: [{ model: "glm-4.7" }, { model: "glm-4.6" }],
billingMode: "subscription",
usageKinds: ["plan:zhipu"], usageKinds: ["plan:zhipu"],
}, },
{ {
@@ -182,6 +228,7 @@ export const providerPresets: ProviderPreset[] = [
{ model: "qwen3-coder-plus" }, { model: "qwen3-coder-plus" },
{ model: "qwen3.5-plus" }, { model: "qwen3.5-plus" },
], ],
billingMode: "pay_as_you_go",
}, },
{ {
id: "minimax", id: "minimax",
@@ -193,6 +240,25 @@ export const providerPresets: ProviderPreset[] = [
baseUrl: "https://api.minimaxi.com/v1", baseUrl: "https://api.minimaxi.com/v1",
icon: "minimax", icon: "minimax",
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }], models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
billingMode: "pay_as_you_go",
usageKinds: ["plan:minimax"],
},
{
id: "minimax-token-plan",
name: "MiniMax Token Plan",
nameKey: "presetNameMinimaxTokenPlan",
websiteUrl: "https://platform.minimaxi.com",
category: "cn_official",
providerType: "openai",
// Token Plan is sold against the same platform endpoint as the
// pay-as-you-go API — only the API key differs. The Rust detect
// routes api.minimaxi.com to plan:minimax, so this preset picks up
// the same query path; the `subscription` tag helps the user pick
// the right entry based on which key they hold.
baseUrl: "https://api.minimaxi.com/v1",
icon: "minimax",
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
billingMode: "subscription",
usageKinds: ["plan:minimax"], usageKinds: ["plan:minimax"],
}, },
{ {
@@ -205,6 +271,7 @@ export const providerPresets: ProviderPreset[] = [
baseUrl: "https://api.stepfun.com/v1", baseUrl: "https://api.stepfun.com/v1",
icon: "stepfun", icon: "stepfun",
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }], models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
billingMode: "pay_as_you_go",
usageKinds: ["balance:stepfun"], usageKinds: ["balance:stepfun"],
}, },
{ {
@@ -229,6 +296,7 @@ export const providerPresets: ProviderPreset[] = [
}, },
], ],
usageKinds: ["balance:siliconflow"], usageKinds: ["balance:siliconflow"],
billingMode: "pay_as_you_go",
}, },
{ {
id: "novita", id: "novita",
@@ -251,6 +319,7 @@ export const providerPresets: ProviderPreset[] = [
{ model: "zai-org/glm-4.6" }, { model: "zai-org/glm-4.6" },
], ],
usageKinds: ["balance:novita"], usageKinds: ["balance:novita"],
billingMode: "pay_as_you_go",
}, },
{ {
id: "openrouter", id: "openrouter",
@@ -273,6 +342,7 @@ export const providerPresets: ProviderPreset[] = [
{ model: "google/gemini-3-pro-preview" }, { model: "google/gemini-3-pro-preview" },
], ],
usageKinds: ["balance:openrouter"], usageKinds: ["balance:openrouter"],
billingMode: "pay_as_you_go",
}, },
{ {
id: "openai", id: "openai",
@@ -288,6 +358,7 @@ export const providerPresets: ProviderPreset[] = [
{ model: "gpt-5.1-codex-max" }, { model: "gpt-5.1-codex-max" },
{ model: "gpt-5-mini" }, { model: "gpt-5-mini" },
], ],
billingMode: "pay_as_you_go",
}, },
{ {
id: "google-genai", id: "google-genai",
@@ -303,6 +374,7 @@ export const providerPresets: ProviderPreset[] = [
{ model: "gemini-3-flash-preview" }, { model: "gemini-3-flash-preview" },
{ model: "gemini-2.5-flash" }, { model: "gemini-2.5-flash" },
], ],
billingMode: "pay_as_you_go",
}, },
{ {
id: "volcengine", id: "volcengine",
@@ -321,6 +393,50 @@ export const providerPresets: ProviderPreset[] = [
}, },
{ model: "kimi-k2-250905", displayName: "Kimi K2" }, { model: "kimi-k2-250905", displayName: "Kimi K2" },
], ],
billingMode: "pay_as_you_go",
},
{
id: "opencode-go",
name: "OpenCode Go",
nameKey: "presetNameOpencodeGo",
// Referral link — supports the OpenCode project
websiteUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
apiKeyUrl: "https://opencode.ai/zen/",
category: "third_party",
providerType: "openai",
baseUrl: "https://opencode.ai/zen/go/v1",
icon: "opencode",
models: [
{ model: "grok-4.5", displayName: "Grok 4.5" },
{ model: "glm-5.2", displayName: "GLM-5.2" },
{ model: "glm-5.1", displayName: "GLM-5.1" },
{ model: "kimi-k3", displayName: "Kimi K3" },
{ model: "kimi-k2.7-code", displayName: "Kimi K2.7 Code" },
{ model: "kimi-k2.6", displayName: "Kimi K2.6" },
{ model: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro" },
{ model: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash" },
],
billingMode: "subscription",
},
{
id: "opencode-zen",
name: "OpenCode Zen",
nameKey: "presetNameOpencodeZen",
websiteUrl: "https://opencode.ai/zen/",
apiKeyUrl: "https://opencode.ai/zen/",
category: "third_party",
providerType: "openai",
baseUrl: "https://opencode.ai/zen/v1",
icon: "opencode",
models: [
{ model: "claude-opus-5", displayName: "Claude Opus 5" },
{ model: "claude-sonnet-5", displayName: "Claude Sonnet 5" },
{ model: "gpt-5.5", displayName: "GPT 5.5" },
{ model: "gemini-3.6-flash", displayName: "Gemini 3.6 Flash" },
{ model: "grok-4.5", displayName: "Grok 4.5" },
{ model: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro" },
],
billingMode: "pay_as_you_go",
}, },
]; ];
+26 -2
View File
@@ -73,6 +73,7 @@ export const enTranslations: Record<TranslationKey, string> = {
fetchingModels: "Fetching...", fetchingModels: "Fetching...",
discoveredModels: "Discovered {count} models", discoveredModels: "Discovered {count} models",
addSelected: "Add selected", addSelected: "Add selected",
alreadyAdded: "Added",
displayName: "Display name", displayName: "Display name",
actualModel: "Actual model", actualModel: "Actual model",
contextSize: "Context size", contextSize: "Context size",
@@ -283,6 +284,7 @@ export const enTranslations: Record<TranslationKey, string> = {
cardDailyTrendSubCapped: "Last 30 days · {n} days total", cardDailyTrendSubCapped: "Last 30 days · {n} days total",
tabModelTrend: "Model usage trend", tabModelTrend: "Model usage trend",
tabProviderTrend: "Provider–model usage trend", tabProviderTrend: "Provider–model usage trend",
tabDailyTrend: "Daily usage trend",
providerUnknown: "Unknown provider", providerUnknown: "Unknown provider",
cardDailyTrendSub: "{n} days", cardDailyTrendSub: "{n} days",
cardModelUsage: "Model usage", cardModelUsage: "Model usage",
@@ -339,10 +341,13 @@ export const enTranslations: Record<TranslationKey, string> = {
presetNameKimiCoding: "Kimi For Coding", presetNameKimiCoding: "Kimi For Coding",
presetNameMoonshot: "Moonshot Platform", presetNameMoonshot: "Moonshot Platform",
presetNameDeepseek: "DeepSeek", presetNameDeepseek: "DeepSeek",
presetNameZhipu: "Zhipu GLM", presetNameZhipuApi: "Zhipu GLM (Pay-as-you-go)",
presetNameZai: "z.ai (Zhipu Global)", presetNameZhipuCoding: "Zhipu GLM Coding Plan",
presetNameZaiApi: "z.ai (Pay-as-you-go)",
presetNameZaiCoding: "z.ai Coding Plan",
presetNameBailian: "Alibaba Bailian", presetNameBailian: "Alibaba Bailian",
presetNameMinimax: "MiniMax", presetNameMinimax: "MiniMax",
presetNameMinimaxTokenPlan: "MiniMax Token Plan",
presetNameStepfun: "StepFun", presetNameStepfun: "StepFun",
presetNameSiliconflow: "SiliconFlow", presetNameSiliconflow: "SiliconFlow",
presetNameNovita: "Novita AI", presetNameNovita: "Novita AI",
@@ -350,6 +355,8 @@ export const enTranslations: Record<TranslationKey, string> = {
presetNameOpenai: "OpenAI", presetNameOpenai: "OpenAI",
presetNameGoogleGenai: "Google AI Studio", presetNameGoogleGenai: "Google AI Studio",
presetNameVolcengine: "Volcengine Ark", presetNameVolcengine: "Volcengine Ark",
presetNameOpencodeGo: "OpenCode Go",
presetNameOpencodeZen: "OpenCode Zen",
// Provider usage // Provider usage
usageBalance: "Balance", usageBalance: "Balance",
@@ -363,4 +370,21 @@ export const enTranslations: Record<TranslationKey, string> = {
usageResetIn: "resets in {time}", usageResetIn: "resets in {time}",
usageResetDone: "reset", usageResetDone: "reset",
usageLoading: "Querying…", usageLoading: "Querying…",
// Billing mode (subscription / pay-as-you-go)
billingTabSubscription: "Subscription",
billingTabPayAsYouGo: "Pay-as-you-go",
billingTabAll: "All",
billingBadgeSubscription: "Plan",
billingBadgePayAsYouGo: "PAYG",
// Usage trend line chart
trendLineTitle: "Usage Trend",
trendLineSub: "last {n} days",
trendLineLegendTokens: "Token usage",
trendLineLegendCacheHit: "Cache hit rate",
trendLineLegendRequests: "Requests",
trendLineShowRequests: "Show requests",
trendLineTooltip: "{date}\nTokens: {tokens}\nCache hit: {cache}\nRequests: {req}",
trendLineNoData: "No data",
}; };
+26 -2
View File
@@ -71,6 +71,7 @@ export const zhTranslations = {
fetchingModels: "获取中...", fetchingModels: "获取中...",
discoveredModels: "发现 {count} 个模型", discoveredModels: "发现 {count} 个模型",
addSelected: "添加选中的", addSelected: "添加选中的",
alreadyAdded: "已添加",
displayName: "显示名称", displayName: "显示名称",
actualModel: "实际请求模型", actualModel: "实际请求模型",
contextSize: "上下文长度", contextSize: "上下文长度",
@@ -278,6 +279,7 @@ export const zhTranslations = {
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天", cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
tabModelTrend: "模型用量趋势", tabModelTrend: "模型用量趋势",
tabProviderTrend: "供应商模型用量趋势", tabProviderTrend: "供应商模型用量趋势",
tabDailyTrend: "每日用量趋势",
providerUnknown: "未知供应商", providerUnknown: "未知供应商",
cardDailyTrendSub: "共 {n} 天", cardDailyTrendSub: "共 {n} 天",
cardModelUsage: "模型用量", cardModelUsage: "模型用量",
@@ -334,10 +336,13 @@ export const zhTranslations = {
presetNameKimiCoding: "Kimi For Coding", presetNameKimiCoding: "Kimi For Coding",
presetNameMoonshot: "Moonshot 平台", presetNameMoonshot: "Moonshot 平台",
presetNameDeepseek: "DeepSeek", presetNameDeepseek: "DeepSeek",
presetNameZhipu: "智谱 GLM", presetNameZhipuApi: "智谱 GLM(按量)",
presetNameZai: "z.ai(智谱海外)", presetNameZhipuCoding: "智谱 GLM Coding Plan",
presetNameZaiApi: "z.ai(按量)",
presetNameZaiCoding: "z.ai Coding Plan",
presetNameBailian: "阿里百炼", presetNameBailian: "阿里百炼",
presetNameMinimax: "MiniMax", presetNameMinimax: "MiniMax",
presetNameMinimaxTokenPlan: "MiniMax Token Plan",
presetNameStepfun: "阶跃星辰", presetNameStepfun: "阶跃星辰",
presetNameSiliconflow: "硅基流动", presetNameSiliconflow: "硅基流动",
presetNameNovita: "Novita AI", presetNameNovita: "Novita AI",
@@ -345,6 +350,8 @@ export const zhTranslations = {
presetNameOpenai: "OpenAI 官方", presetNameOpenai: "OpenAI 官方",
presetNameGoogleGenai: "Google AI Studio", presetNameGoogleGenai: "Google AI Studio",
presetNameVolcengine: "火山方舟", presetNameVolcengine: "火山方舟",
presetNameOpencodeGo: "OpenCode Go",
presetNameOpencodeZen: "OpenCode Zen",
// Provider usage // Provider usage
usageBalance: "余额", usageBalance: "余额",
@@ -358,6 +365,23 @@ export const zhTranslations = {
usageResetIn: "{time} 后重置", usageResetIn: "{time} 后重置",
usageResetDone: "已重置", usageResetDone: "已重置",
usageLoading: "查询中…", usageLoading: "查询中…",
// Billing mode (套餐 / 按量)
billingTabSubscription: "套餐",
billingTabPayAsYouGo: "按量",
billingTabAll: "全部",
billingBadgeSubscription: "套餐",
billingBadgePayAsYouGo: "按量",
// Usage trend line chart
trendLineTitle: "用量趋势",
trendLineSub: "最近 {n} 天",
trendLineLegendTokens: "Token 用量",
trendLineLegendCacheHit: "缓存命中率",
trendLineLegendRequests: "请求数",
trendLineShowRequests: "显示请求数",
trendLineTooltip: "{date}\nToken: {tokens}\n命中率: {cache}\n请求: {req}",
trendLineNoData: "暂无数据",
} as const; } as const;
export type TranslationKey = keyof typeof zhTranslations; export type TranslationKey = keyof typeof zhTranslations;