feat: v0.4.0 - provider icon system, settings modal & polish

- Add provider icon system with extracted icon library (cc-switch based)

- Persist icon and icon_color in SQLite and config.toml

- Add Settings modal (theme / language / update check / silent download)

- Add useTheme and useUpdateCheck hooks

- Add provider icon picker (IconPicker) with search and grid

- Fix icon frame 2-layer color difference in ProviderEdit

- Dashboard heatmap and daily usage improvements

- Sessions page polish and adaptive layout

- Bump version to 0.4.0
This commit is contained in:
KimiSwitch Dev committed 2026-07-28 16:01:11 +08:00
1 parent 14d48500bf
commit ebf2dd28d5
134 files changed
+2898 -326

No files matched your search

+5 -5
View File
@@ -49,7 +49,7 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
if (!daily?.length) {
return (
<div className="flex h-48 items-center justify-center text-sm text-gray-500">
<div className="flex h-48 items-center justify-center text-sm text-content-muted">
{t("noData")}
</div>
);
@@ -58,11 +58,11 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
const max = Math.max(...daily.map((d) => d.totalTokens || 0), 1);
return (
<div className="space-y-3">
<div className="flex h-full flex-col gap-3">
{/* Legend */}
<div className="flex flex-wrap gap-x-4 gap-y-1">
{modelNames.map((m) => (
<span key={m} className="flex items-center gap-1.5 text-[11px] text-gray-400">
<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] }}
@@ -73,7 +73,7 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
</div>
{/* Bars */}
<div className="flex h-52 items-end gap-1 overflow-x-auto px-0.5 pb-1">
<div className="flex min-h-[180px] flex-1 items-end gap-1 overflow-x-auto px-0.5 pb-1">
{daily.map((d) => {
const h = Math.max(4, Math.round(((d.totalTokens || 0) / max) * 170));
const total = d.totalTokens || 1;
@@ -121,7 +121,7 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
/>
))}
</div>
<span className="whitespace-nowrap text-[10px] text-gray-500">
<span className="whitespace-nowrap text-[10px] text-content-muted">
{d.date.slice(5)}
</span>
</button>
+17 -17
View File
@@ -56,16 +56,16 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
aria-label={day.date}
>
<div
className="w-full max-w-lg rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-2xl"
className="w-full max-w-lg rounded-xl border border-border bg-panel shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between border-b border-[#2a2a2e] px-5 py-3.5">
<div className="flex items-start justify-between border-b border-border px-5 py-3.5">
<div>
<div className="text-[11px] uppercase tracking-wider text-gray-500">
<div className="text-[11px] uppercase tracking-wider text-content-muted">
{t("dayDetail")}
</div>
<div className="mt-0.5 text-lg font-semibold text-[#e5e5e7]">
<div className="mt-0.5 text-lg font-semibold text-content-primary">
{day.date}
</div>
</div>
@@ -73,7 +73,7 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
type="button"
onClick={onClose}
aria-label={t("close")}
className="rounded-md p-1 text-gray-500 hover:bg-[#2a2a2e] hover:text-gray-200 transition-colors"
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"
@@ -117,11 +117,11 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
{/* Per-model breakdown */}
<div className="px-5 pb-4">
<div className="mb-2 text-[11px] uppercase tracking-wider text-gray-500">
<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-gray-500">
<div className="py-6 text-center text-sm text-content-muted">
{t("noData")}
</div>
) : (
@@ -135,19 +135,19 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span
className="truncate text-[#e5e5e7]"
className="truncate text-content-primary"
title={s.model}
>
{shortModel(s.model)}
</span>
<span className="shrink-0 text-xs text-gray-400 tabular-nums">
<span className="shrink-0 text-xs text-content-muted tabular-nums">
{fmtTokens(s.tokens)}{" "}
<span className="text-gray-500">
<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-[#2a2a2e]">
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-border">
<div
className="h-full rounded-full transition-all"
style={{
@@ -164,7 +164,7 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
</div>
{/* Footer hint */}
<div className="border-t border-[#2a2a2e] px-5 py-2 text-[11px] text-gray-500">
<div className="border-t border-border px-5 py-2 text-[11px] text-content-muted">
{t("modalEscHint")}
</div>
</div>
@@ -183,15 +183,15 @@ function SummaryChip({
}) {
const valueClass =
tone === "orange"
? "text-orange-400"
? "text-orange-600 dark:text-orange-400"
: tone === "blue"
? "text-blue-400"
: tone === "green"
? "text-emerald-400"
: "text-[#e5e5e7]";
? "text-emerald-600 dark:text-emerald-400"
: "text-content-primary";
return (
<div className="rounded-md bg-[#1c1c20] px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-gray-500">
<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}`}>
+54 -52
View File
@@ -1,4 +1,5 @@
import { useState, type ReactNode } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import { DailyBars } from "./DailyBars";
@@ -21,10 +22,10 @@ function Card({
children: ReactNode;
}) {
return (
<div className="rounded-xl border border-[#2a2a2e] bg-[#16161a]">
<div className="border-b border-[#2a2a2e] px-4 py-2.5 flex items-baseline justify-between">
<h3 className="text-sm font-medium text-[#e5e5e7]">{title}</h3>
{subtitle && <span className="text-xs text-gray-500">{subtitle}</span>}
<div className="rounded-xl border border-border bg-panel">
<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>
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
</div>
<div className="p-4">{children}</div>
</div>
@@ -33,12 +34,12 @@ function Card({
function KpiCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-xl border border-[#2a2a2e] bg-[#16161a] p-3">
<div className="text-[11px] text-gray-500">{label}</div>
<div className="mt-1 text-lg font-semibold text-[#e5e5e7] tracking-tight">
<div className="rounded-xl border border-border bg-panel p-3">
<div className="text-[11px] text-content-muted">{label}</div>
<div className="mt-1 text-lg font-semibold text-content-primary tracking-tight">
{value}
</div>
{sub && <div className="mt-0.5 text-[11px] text-gray-500">{sub}</div>}
{sub && <div className="mt-0.5 text-[11px] text-content-muted">{sub}</div>}
</div>
);
}
@@ -50,7 +51,7 @@ export function DashboardPage() {
if (loading && !data) {
return (
<div className="flex h-full items-center justify-center text-gray-500">
<div className="flex h-full items-center justify-center text-content-muted">
<div className="animate-spin h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full" />
</div>
);
@@ -61,7 +62,7 @@ export function DashboardPage() {
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="text-red-400 mb-2">加载失败</div>
<div className="text-sm text-gray-500">{error}</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"
@@ -109,7 +110,7 @@ export function DashboardPage() {
<div className="h-full overflow-auto p-4 space-y-4">
{/* Range tabs + refresh */}
<div className="flex items-center justify-between">
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
<div className="flex items-center bg-input border border-border rounded p-0.5">
{RANGES.map((r) => (
<button
key={r.value}
@@ -118,7 +119,7 @@ export function DashboardPage() {
className={`px-3 py-1 text-sm rounded transition-colors ${
range === r.value
? "bg-blue-600 text-white"
: "text-gray-400 hover:text-[#e5e5e7]"
: "text-content-muted hover:text-content-primary"
}`}
>
{r.label}
@@ -129,7 +130,7 @@ export function DashboardPage() {
type="button"
onClick={() => refresh()}
disabled={loading}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] text-gray-400 hover:text-[#e5e5e7] disabled:opacity-50"
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 ? "刷新中…" : "↻ 刷新"}
</button>
@@ -149,14 +150,14 @@ export function DashboardPage() {
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-[#2a2a2e] bg-[#16161a] hover:border-[#3a3a42] hover:bg-[#1c1c20]"
: "border-border bg-panel hover:border-strong hover:bg-hover"
}`}
>
<div className="text-xs text-gray-500">{r.label}</div>
<div className="mt-1 text-lg font-semibold text-[#e5e5e7] tracking-tight">
<div className="text-xs text-content-muted">{r.label}</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-gray-500">
<div className="mt-1 text-[11px] text-content-muted">
{fmtInt(tot.requests)} 次 · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
</div>
</button>
@@ -186,20 +187,22 @@ export function DashboardPage() {
: `按模型着色 · ${daily.length} 天`
}
>
<DailyBars
daily={daily.length > 30 ? daily.slice(-30) : daily}
modelNames={modelNames}
/>
<div className="h-full min-h-[240px]">
<DailyBars
daily={daily.length > 30 ? daily.slice(-30) : daily}
modelNames={modelNames}
/>
</div>
</Card>
<Card title="模型用量" subtitle={`${models.length} 个模型`}>
{models.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500">暂无数据</div>
<div className="py-8 text-center text-sm text-content-muted">暂无数据</div>
) : (
<div className="max-h-[360px] overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[#16161a]">
<tr className="text-left text-xs text-gray-500 border-b border-[#2a2a2e]">
<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>
@@ -211,7 +214,7 @@ export function DashboardPage() {
{visibleModels.map((m) => (
<tr
key={m.model}
className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]"
className="border-b border-border/50 hover:bg-hover"
>
<td className="py-2 pr-3">
<div className="flex items-center gap-2">
@@ -225,17 +228,17 @@ export function DashboardPage() {
}}
/>
<div className="min-w-0">
<div className="text-[#e5e5e7] truncate">{m.modelDisplay || m.model}</div>
<div className="text-content-primary truncate">{m.modelDisplay || m.model}</div>
{m.costEstimated && (
<span className="text-[10px] text-yellow-600">估算</span>
)}
</div>
</div>
</td>
<td className="py-2 pr-3 text-right text-gray-400">{fmtInt(m.requests)}</td>
<td className="py-2 pr-3 text-right text-gray-400">{fmtTokens(m.totalTokens)}</td>
<td className="py-2 pr-3 text-right text-gray-400">{fmtPct(m.cacheHitRate)}</td>
<td className="py-2 text-right text-orange-400">{fmtUsd(m.costUsd)}</td>
<td className="py-2 pr-3 text-right text-content-muted">{fmtInt(m.requests)}</td>
<td className="py-2 pr-3 text-right text-content-muted">{fmtTokens(m.totalTokens)}</td>
<td className="py-2 pr-3 text-right text-content-muted">{fmtPct(m.cacheHitRate)}</td>
<td className="py-2 text-right text-orange-600 dark:text-orange-400">{fmtUsd(m.costUsd)}</td>
</tr>
))}
</tbody>
@@ -264,13 +267,13 @@ export function DashboardPage() {
}
>
{recent.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500">暂无数据</div>
<div className="py-8 text-center text-sm text-content-muted">暂无数据</div>
) : (
<>
<div className="overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[#16161a]">
<tr className="text-left text-xs text-gray-500 border-b border-[#2a2a2e]">
<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>
@@ -281,15 +284,15 @@ export function DashboardPage() {
</thead>
<tbody>
{recentPageRows.map((r, i) => (
<tr key={recentStart + i} className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]">
<td className="py-1.5 pr-4 text-gray-500 whitespace-nowrap">
<tr key={recentStart + i} className="border-b border-border/50 hover:bg-hover">
<td className="py-1.5 pr-4 text-content-muted whitespace-nowrap">
{fmtTime(r.time)}
</td>
<td className="py-1.5 pr-4 text-[#e5e5e7]">{r.modelDisplay || r.model}</td>
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.inputOther)}</td>
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.output)}</td>
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.inputCacheRead)}</td>
<td className="py-1.5 text-right text-orange-400">{fmtUsd(r.costUsd)}</td>
<td className="py-1.5 pr-4 text-content-primary">{r.modelDisplay || r.model}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputOther)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.output)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputCacheRead)}</td>
<td className="py-1.5 text-right text-orange-600 dark:text-orange-400">{fmtUsd(r.costUsd)}</td>
</tr>
))}
</tbody>
@@ -297,7 +300,7 @@ export function DashboardPage() {
</div>
{/* Pagination footer */}
<div className="mt-2 flex items-center justify-between gap-2 border-t border-[#2a2a2e] pt-2 text-xs text-gray-500">
<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))} 条 ·{" "}
@@ -308,7 +311,7 @@ export function DashboardPage() {
type="button"
disabled={recentCurrentPage <= 1}
onClick={() => setRecentPage(1)}
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="首页"
>
«
@@ -317,7 +320,7 @@ export function DashboardPage() {
type="button"
disabled={recentCurrentPage <= 1}
onClick={() => setRecentPage((p) => Math.max(1, p - 1))}
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="上一页"
>
‹
@@ -326,7 +329,7 @@ export function DashboardPage() {
type="button"
disabled={recentCurrentPage >= recentTotalPages}
onClick={() => setRecentPage((p) => Math.min(recentTotalPages, p + 1))}
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="下一页"
>
›
@@ -335,7 +338,7 @@ export function DashboardPage() {
type="button"
disabled={recentCurrentPage >= recentTotalPages}
onClick={() => setRecentPage(recentTotalPages)}
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="末页"
>
»
@@ -347,19 +350,18 @@ export function DashboardPage() {
</Card>
{/* Attribution footer */}
<div className="text-xs text-gray-500 pb-2">
<div className="text-xs text-content-muted pb-2">
扫描 {data.meta.filesScanned} 个文件 · {fmtInt(data.meta.recordCount)} 条记录 ·{" "}
{data.meta.home}
<br />
仪表盘功能基于{" "}
<a
href="https://github.com/JochenYang/kimicode-dashboard"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-400 underline"
<button
type="button"
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
kimicode-dashboard
</a>{" "}
</button>{" "}
(MIT,© JochenYang)移植
</div>
</div>
+9 -9
View File
@@ -9,7 +9,7 @@ interface HeatmapProps {
}
const LEVEL_BG = [
"bg-[#1a1a1e]", // 0 — empty / no usage
"bg-cell-empty", // 0 — empty / no usage
"bg-emerald-900/55",
"bg-emerald-700/65",
"bg-emerald-500/85",
@@ -101,7 +101,7 @@ export function Heatmap({ heatmap }: HeatmapProps) {
if (!cells.length) {
return (
<div className="flex h-28 items-center justify-center text-sm text-gray-500">
<div className="flex h-28 items-center justify-center text-sm text-content-muted">
{t("noData")}
</div>
);
@@ -129,7 +129,7 @@ export function Heatmap({ heatmap }: HeatmapProps) {
return (
<div
key={wi}
className="text-[10px] leading-none text-gray-500 whitespace-nowrap overflow-visible"
className="text-[10px] leading-none text-content-muted whitespace-nowrap overflow-visible"
>
{label ? label.label : ""}
</div>
@@ -146,7 +146,7 @@ export function Heatmap({ heatmap }: HeatmapProps) {
{dowLabels.map((d, i) => (
<div
key={d}
className={`flex items-center text-[10px] text-gray-500 ${
className={`flex items-center text-[10px] text-content-muted ${
i % 2 === 1 ? "opacity-100" : "opacity-0"
}`}
style={{ height: CELL }}
@@ -193,7 +193,7 @@ export function Heatmap({ heatmap }: HeatmapProps) {
</div>
</div>
<div className="flex items-center justify-between gap-3 text-[11px] text-gray-500">
<div className="flex items-center justify-between gap-3 text-[11px] text-content-muted">
<span>
{heatmap.start} → {heatmap.end}
</span>
@@ -234,15 +234,15 @@ function HeatmapTooltip({ state }: { state: HoverState }) {
return (
<div
style={style}
className="rounded-md border border-gray-700 bg-gray-900 px-3 py-1.5 text-xs text-gray-100 shadow-xl"
className="rounded-md border border-border bg-panel px-3 py-1.5 text-xs text-content-primary shadow-xl"
role="tooltip"
>
<div className="font-medium text-gray-50">{cell.date}</div>
<div className="text-gray-400">
<div className="font-medium text-content-primary">{cell.date}</div>
<div className="text-content-muted">
{fmtTokens(cell.totalTokens)} · {fmtUsd(cell.costUsd)} ·{" "}
{fmtInt(cell.requests)} {t("requests")}
</div>
<div className="text-gray-400">
<div className="text-content-muted">
{t("cacheHitRate")}: {fmtPct(cell.cacheHitRate)}
</div>
</div>