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

+109 -39
View File
@@ -2,7 +2,12 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "../i18n";
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";
interface PresetPickerModalProps {
@@ -26,12 +31,26 @@ const CATEGORY_LABEL: Record<Exclude<PresetCategory, "custom">, TranslationKey>
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 BillingTab = "all" | BillingMode;
const BILLING_TAB_ORDER: BillingTab[] = ["subscription", "pay_as_you_go", "all"];
export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("original");
const [billingTab, setBillingTab] = useState<BillingTab>("all");
// Esc to close
useEffect(() => {
@@ -58,15 +77,31 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
if (open) {
setQuery("");
setSort("original");
setBillingTab("all");
}
}, [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) =>
p.nameKey ? t(p.nameKey as TranslationKey) : p.name;
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
let list = providerPresets;
if (billingTab !== "all") {
list = list.filter((p) => p.billingMode === billingTab);
}
if (q) {
list = list.filter(
(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;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, sort, t]);
}, [query, sort, billingTab, t]);
if (!open) return null;
@@ -110,39 +145,67 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
</button>
</div>
{/* Search + sort */}
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
<input
type="text"
value={query}
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>
{/* Billing-mode tabs + search + sort */}
<div className="flex flex-col gap-2 border-b border-border px-5 py-3">
<div className="flex items-center bg-input border border-border rounded p-0.5 self-start">
{BILLING_TAB_ORDER.map((tab) => {
const labelKey: TranslationKey =
tab === "all"
? "billingTabAll"
: tab === "subscription"
? "billingTabSubscription"
: "billingTabPayAsYouGo";
const count = billingCounts[tab];
const active = billingTab === tab;
return (
<button
key={tab}
type="button"
onClick={() => setBillingTab(tab)}
className={`px-2.5 py-1 text-xs rounded transition-colors ${
active
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{t(labelKey)} ({count})
</button>
);
})}
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={query}
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>
@@ -168,13 +231,20 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
<div className="w-full truncate text-sm font-medium text-content-primary">
{presetName(p)}
</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
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>
)}
</div>
</button>
))}
</div>