Files
TokenLens/src/renderer/App.jsx

461 lines
14 KiB
JavaScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
BarChart3,
CalendarDays,
ChevronDown,
Check,
Info,
Layers3,
RefreshCcw,
Sparkles,
TrendingUp
} from "lucide-react";
import { sampleSummary } from "./sampleData";
import { formatTokensZh } from "./displayFormat";
import "./styles.css";
const providers = ["Claude", "Codex", "Gemini"];
const providerColors = {
Claude: "#2e83ff",
Codex: "#9b5cf6",
Gemini: "#2dd4cf"
};
function App() {
const [summary, setSummary] = useState(sampleSummary);
const [status, setStatus] = useState("loading");
const [lastUpdated, setLastUpdated] = useState("");
const [refreshCount, setRefreshCount] = useState(0);
const [range, setRange] = useState("近 30 天");
const [trendMode, setTrendMode] = useState("每日");
const [shareMode, setShareMode] = useState("按 Tokens");
const [toolFilter, setToolFilter] = useState("全部工具");
const scanningRef = useRef(false);
const refresh = useCallback(async ({ silent = false } = {}) => {
if (scanningRef.current) return;
scanningRef.current = true;
if (!silent) setStatus("loading");
try {
if (window.tokenLens?.scanUsage) {
const data = await window.tokenLens.scanUsage();
setSummary(data);
setStatus("ready");
setLastUpdated(new Date(data.generatedAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }));
setRefreshCount((count) => count + 1);
} else {
setSummary(sampleSummary);
setStatus("preview");
setLastUpdated("预览数据");
}
} catch (error) {
setStatus("error");
setLastUpdated("扫描失败");
console.error(error);
} finally {
scanningRef.current = false;
}
}, []);
useEffect(() => {
refresh();
const timer = window.setInterval(() => {
refresh({ silent: true });
}, 15_000);
return () => window.clearInterval(timer);
}, [refresh]);
const totalByProvider = useMemo(() => providers.reduce((sum, provider) => {
return sum + (summary.providerTotals?.[provider]?.totalTokens || 0);
}, 0), [summary]);
const sourceRows = summary.sourceRows?.length ? summary.sourceRows : sampleSummary.sourceRows;
const filteredRows = toolFilter === "全部工具" ? sourceRows : sourceRows.filter((row) => row.provider === toolFilter);
const insights = summary.insights?.length ? summary.insights.slice(0, 3) : sampleSummary.insights;
return (
<div className="app-shell">
<main className="main-pane">
<TopBar
onRefresh={() => refresh()}
status={status}
lastUpdated={lastUpdated}
refreshCount={refreshCount}
range={range}
setRange={setRange}
/>
<section className="dashboard">
<MetricCards summary={summary} />
<section className="middle-grid">
<Panel
title="Tokens 使用趋势"
control={<Dropdown value={trendMode} options={["每日", "每周"]} onChange={setTrendMode} />}
>
<TrendChart data={summary.dailyTrend || []} />
</Panel>
<Panel
title="使用占比"
control={<Dropdown value={shareMode} options={["按 Tokens", "按占比"]} onChange={setShareMode} />}
>
<UsageShare providerTotals={summary.providerTotals || {}} total={totalByProvider} />
</Panel>
</section>
<section className="bottom-grid">
<ProviderBreakdown rows={filteredRows} toolFilter={toolFilter} setToolFilter={setToolFilter} />
<Insights insights={insights} />
</section>
</section>
</main>
</div>
);
}
function TopBar({ onRefresh, status, lastUpdated, refreshCount, range, setRange }) {
return (
<header className="topbar">
<div className="brand">
<BarChart3 size={23} />
<span>TokenLens 用量统计</span>
<span className={`live-pill ${status}`}>
<i />
{status === "error" ? "扫描异常" : "每 15 秒自动刷新"}
</span>
</div>
<div className="toolbar">
<Dropdown
value={range}
options={["近 7 天", "近 30 天", "本月", "全部"]}
onChange={setRange}
icon={<CalendarDays size={18} />}
className="toolbar-dropdown"
/>
<button className="toolbar-button primary" onClick={onRefresh}>
<RefreshCcw size={18} className={status === "loading" ? "spin" : ""} />
同步
</button>
<div className="refresh-meta">
<span>{lastUpdated ? `上次更新 ${lastUpdated}` : "准备扫描"}</span>
<small>已刷新 {refreshCount} </small>
</div>
</div>
</header>
);
}
function MetricCards({ summary }) {
const cards = [
{
label: "今日 Tokens",
value: formatTokensZh(summary.cards?.todayTokens || 0),
detail: "本地今日已统计",
trend: "实时",
tone: "blue",
icon: Layers3
},
{
label: "本月 Tokens",
value: formatTokensZh(summary.cards?.monthTokens || 0),
detail: "按本地时区汇总",
trend: "月度",
tone: "green",
icon: Sparkles
},
{
label: "历史总量",
value: formatTokensZh(summary.cards?.totalTokens || 0),
detail: `已扫描 ${summary.cards?.sessionCount || 0} 个会话`,
trend: "全部",
tone: "violet",
icon: Check
},
{
label: "缓存命中",
value: `${summary.cards?.cacheHitRate || 0}%`,
detail: "来自缓存上下文",
trend: "缓存",
tone: "orange",
icon: TrendingUp
}
];
return (
<section className="metric-grid">
{cards.map((card, index) => {
const Icon = card.icon;
return (
<article className="metric-card" key={card.label} style={{ "--i": index }}>
<div className={`metric-icon ${card.tone}`}>
<Icon size={25} />
</div>
<div className="metric-copy">
<div className="metric-label">{card.label}<Info size={14} /></div>
<div className="metric-value">{card.value}</div>
<div className="metric-detail">
<strong className={card.tone}>{card.trend}</strong>
<span>{card.detail}</span>
</div>
</div>
</article>
);
})}
</section>
);
}
function Panel({ title, control, children }) {
return (
<article className="panel">
<div className="panel-header">
<h2>{title}</h2>
{control}
</div>
{children}
</article>
);
}
function Dropdown({ value, options, onChange, icon, className = "" }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
function close(event) {
if (!ref.current?.contains(event.target)) setOpen(false);
}
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, []);
return (
<div className={`dropdown ${className}`} ref={ref}>
<button className="dropdown-trigger" onClick={() => setOpen((current) => !current)}>
{icon}
{value}
<ChevronDown size={16} className={open ? "chevron open" : "chevron"} />
</button>
{open ? (
<div className="dropdown-menu">
{options.map((option) => (
<button
className={option === value ? "selected" : ""}
key={option}
onClick={() => {
onChange(option);
setOpen(false);
}}
>
{option}
</button>
))}
</div>
) : null}
</div>
);
}
function TrendChart({ data }) {
const chartData = data.length ? data.slice(-24) : sampleSummary.dailyTrend;
const width = 640;
const height = 235;
const left = 58;
const right = 18;
const top = 18;
const bottom = 34;
const maxValue = Math.max(1, ...chartData.flatMap((item) => providers.map((provider) => item[provider] || 0)));
const plotWidth = width - left - right;
const plotHeight = height - top - bottom;
const x = (index) => left + (index / Math.max(chartData.length - 1, 1)) * plotWidth;
const y = (value) => top + plotHeight - (value / maxValue) * plotHeight;
const lines = providers.map((provider) => ({
provider,
points: chartData.map((item, index) => `${x(index)},${y(item[provider] || 0)}`).join(" ")
}));
const labelIndexes = [0, 6, 12, 18, chartData.length - 1].filter((index, pos, arr) => index >= 0 && arr.indexOf(index) === pos);
return (
<>
<svg className="trend-chart" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Tokens 使用趋势">
{[0, 0.25, 0.5, 0.75, 1].map((step) => {
const lineY = top + plotHeight * step;
const value = maxValue * (1 - step);
return (
<g key={step}>
<line className="grid-line" x1={left} y1={lineY} x2={width - right} y2={lineY} />
<text className="axis-label" x="12" y={lineY + 4}>{formatTokensZh(value)}</text>
</g>
);
})}
{labelIndexes.map((index) => (
<text className="axis-label" key={index} x={x(index) - 20} y={height - 8}>
{shortDate(chartData[index]?.date)}
</text>
))}
{lines.map((line) => (
<polyline
key={line.provider}
className="trend-line"
points={line.points}
style={{ stroke: providerColors[line.provider] }}
/>
))}
{lines.map((line) => chartData.filter((_, index) => index % 5 === 0 || index === chartData.length - 1).map((item, dotIndex) => (
<circle
key={`${line.provider}-${dotIndex}`}
cx={x(chartData.indexOf(item))}
cy={y(item[line.provider] || 0)}
r="4"
fill={providerColors[line.provider]}
/>
)))}
</svg>
<Legend />
</>
);
}
function UsageShare({ providerTotals, total }) {
const safeTotal = total || 1;
let cursor = 0;
const gradient = providers.map((provider) => {
const share = ((providerTotals[provider]?.totalTokens || 0) / safeTotal) * 360;
const start = cursor;
cursor += share;
return `${providerColors[provider]} ${start}deg ${cursor}deg`;
}).join(", ");
return (
<div className="share-body">
<div className="donut" style={{ background: `conic-gradient(${gradient})` }}>
<div className="donut-center">
<strong>{formatTokensZh(total)}</strong>
<span>本月</span>
</div>
</div>
<div className="share-list">
{providers.map((provider) => {
const providerTotal = providerTotals[provider]?.totalTokens || 0;
const percent = total ? Math.round((providerTotal / total) * 100) : 0;
return (
<div className="share-row" key={provider}>
<div>
<span className="legend-name"><i style={{ background: providerColors[provider] }} />{provider}</span>
<small>{formatTokensZh(providerTotal)} Tokens</small>
</div>
<strong>{percent}%</strong>
</div>
);
})}
</div>
</div>
);
}
function ProviderBreakdown({ rows, toolFilter, setToolFilter }) {
return (
<article className="panel table-panel">
<div className="panel-header table-title">
<h2>工具明细</h2>
<Dropdown value={toolFilter} options={["全部工具", "Claude", "Codex", "Gemini"]} onChange={setToolFilter} />
</div>
<table>
<thead>
<tr>
<th>工具</th>
<th>输入</th>
<th>输出</th>
<th>缓存</th>
<th>总量</th>
<th>趋势</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${row.provider}-${row.source}-${index}`}>
<td><ProviderName provider={row.provider} /></td>
<td>{formatTokensZh(row.inputTokens)}</td>
<td>{formatTokensZh(row.outputTokens)}</td>
<td>{formatTokensZh(row.cachedTokens)}</td>
<td>{formatTokensZh(row.totalTokens)}</td>
<td><Sparkline provider={row.provider} seed={index} /></td>
<td className="more"></td>
</tr>
))}
</tbody>
</table>
<div className="table-footer">
<span>显示 {rows.length} 个本地工具 · 不上传对话内容</span>
<button>查看原始报告 </button>
</div>
</article>
);
}
function ProviderName({ provider }) {
return (
<div className="provider-name">
<span className={`provider-logo ${provider.toLowerCase()}`}>{provider === "Gemini" ? "" : provider[0]}</span>
{provider}
</div>
);
}
function Sparkline({ provider, seed }) {
const base = [
[17, 12, 16, 8, 12, 6, 10, 4, 8, 5, 9],
[18, 11, 15, 17, 10, 13, 7, 12, 6, 8, 4],
[16, 16, 10, 18, 14, 9, 12, 6, 5, 8, 8]
][seed % 3];
const points = base.map((value, index) => `${index * 7},${value}`).join(" ");
return (
<svg className="sparkline" viewBox="0 0 74 22">
<polyline points={points} style={{ stroke: providerColors[provider] || "#4b99ff" }} />
</svg>
);
}
function Insights({ insights }) {
return (
<article className="panel insight-panel">
<div className="panel-header table-title">
<h2>洞察提醒</h2>
<button>查看全部</button>
</div>
<div className="insight-list">
{insights.map((insight, index) => (
<div className={`insight ${insight.tone}`} key={`${insight.title}-${index}`}>
<div className="insight-icon">{insight.tone === "good" ? "✓" : insight.tone === "warn" ? "!" : "↗"}</div>
<div className="insight-time">{index + 1} 小时</div>
<strong>{insight.title}</strong>
<span>{insight.copy}</span>
</div>
))}
</div>
</article>
);
}
function Legend() {
return (
<div className="legend">
{providers.map((provider) => (
<span key={provider}><i style={{ background: providerColors[provider] }} />{provider}</span>
))}
</div>
);
}
function shortDate(value) {
if (!value) return "";
const [, month, day] = value.split("-");
return `${month}/${day}`;
}
createRoot(document.getElementById("root")).render(<App />);