461 lines
14 KiB
JavaScript
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 />);
|