chore: add TokenLens sources and ignore rules
This commit is contained in:
460
src/renderer/App.jsx
Normal file
460
src/renderer/App.jsx
Normal file
@@ -0,0 +1,460 @@
|
||||
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 />);
|
||||
Reference in New Issue
Block a user