token-studio 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.nvmrc +1 -0
- package/CHANGELOG.md +89 -0
- package/Dockerfile +17 -0
- package/LICENSE +22 -0
- package/NOTICE.md +21 -0
- package/PRIVACY.md +68 -0
- package/README.en.md +220 -0
- package/README.md +220 -0
- package/config/collectors.json +54 -0
- package/data/.gitkeep +1 -0
- package/docker-compose.yml +17 -0
- package/docs/assets/.gitkeep +1 -0
- package/docs/assets/token-studio-v44-dashboard.png +0 -0
- package/docs/assets/token-studio-v44-live.png +0 -0
- package/docs/assets/token-studio-v44-review-mobile.png +0 -0
- package/docs/assets/token-studio-v44-review.png +0 -0
- package/docs/assets/token-studio-v45-dashboard.png +0 -0
- package/docs/assets/token-studio-v45-live.png +0 -0
- package/docs/assets/token-studio-v45-review-mobile.png +0 -0
- package/docs/assets/token-studio-v45-review.png +0 -0
- package/docs/blog-case-study.md +34 -0
- package/docs/collector-support-matrix.md +65 -0
- package/docs/competitive-notes.md +87 -0
- package/docs/demo-data/README.md +12 -0
- package/docs/demo-data/token-studio-v2-demo.json +146 -0
- package/docs/demo-flow.md +39 -0
- package/docs/first-run.md +95 -0
- package/docs/local-collectors.md +49 -0
- package/docs/public-launch-checklist.md +45 -0
- package/docs/resume-bullets.md +7 -0
- package/docs/statusline.md +52 -0
- package/index.html +16 -0
- package/package.json +36 -0
- package/render.yaml +17 -0
- package/src/auto-attribution.mjs +396 -0
- package/src/ccusage-bridge.mjs +74 -0
- package/src/ccusage-import.mjs +415 -0
- package/src/cli.mjs +643 -0
- package/src/client/dashboard/App.jsx +1734 -0
- package/src/client/dashboard/annotation-presets.js +138 -0
- package/src/client/dashboard/attribution.js +328 -0
- package/src/client/dashboard/components-charts.jsx +622 -0
- package/src/client/dashboard/components-tables.jsx +1531 -0
- package/src/client/dashboard/components-top.jsx +307 -0
- package/src/client/dashboard/import-budget.js +41 -0
- package/src/client/dashboard/model-usage.js +108 -0
- package/src/client/dashboard/onboarding.js +80 -0
- package/src/client/dashboard/styles.css +2606 -0
- package/src/client/live/LiveApp.jsx +226 -0
- package/src/client/live/styles.css +446 -0
- package/src/client/main.jsx +20 -0
- package/src/client/review/ReviewApp.jsx +507 -0
- package/src/client/review/closure-progress.js +165 -0
- package/src/client/review/markdown-report.js +401 -0
- package/src/client/review/model-strategy.js +273 -0
- package/src/client/review/roi-advisor.js +255 -0
- package/src/client/review/roi-evidence.js +78 -0
- package/src/client/review/savings-simulator.js +252 -0
- package/src/client/review/sections-1.jsx +277 -0
- package/src/client/review/sections-2.jsx +927 -0
- package/src/client/review/styles.css +2321 -0
- package/src/client/review/utils.js +345 -0
- package/src/client/shared/utils.js +236 -0
- package/src/closure-check.mjs +537 -0
- package/src/closure-import.mjs +646 -0
- package/src/collect.mjs +247 -0
- package/src/collector-config.mjs +82 -0
- package/src/collector-registry.mjs +333 -0
- package/src/collectors/claude-code.mjs +355 -0
- package/src/collectors/codex.mjs +418 -0
- package/src/collectors/copilot.mjs +19 -0
- package/src/collectors/cursor.mjs +23 -0
- package/src/collectors/gemini.mjs +530 -0
- package/src/collectors/goose.mjs +15 -0
- package/src/collectors/hermes.mjs +206 -0
- package/src/collectors/kimi.mjs +15 -0
- package/src/collectors/openclaw.mjs +400 -0
- package/src/collectors/opencode.mjs +349 -0
- package/src/collectors/qwen.mjs +15 -0
- package/src/collectors/structured-usage.mjs +437 -0
- package/src/collectors/utils.mjs +93 -0
- package/src/db.mjs +1397 -0
- package/src/demo-seed.mjs +39 -0
- package/src/dev.mjs +43 -0
- package/src/live.mjs +428 -0
- package/src/model-policy.mjs +147 -0
- package/src/pricing.mjs +434 -0
- package/src/privacy-check.mjs +126 -0
- package/src/server.mjs +1240 -0
- package/src/source-health.mjs +195 -0
- package/src/statusline.mjs +156 -0
- package/src/terminal-report.mjs +245 -0
- package/src/update-pricing.mjs +8 -0
- package/test/annotation-presets.test.mjs +137 -0
- package/test/api-annotations.test.mjs +202 -0
- package/test/api-auto-attribution.test.mjs +169 -0
- package/test/api-source-health.test.mjs +109 -0
- package/test/api-v2.test.mjs +278 -0
- package/test/api-v43.test.mjs +151 -0
- package/test/api-v44.test.mjs +128 -0
- package/test/attribution-summary.test.mjs +164 -0
- package/test/auto-attribution.test.mjs +116 -0
- package/test/ccusage-bridge.test.mjs +36 -0
- package/test/ccusage-import.test.mjs +93 -0
- package/test/cli-v43.test.mjs +64 -0
- package/test/cli-v45.test.mjs +34 -0
- package/test/cli-v46.test.mjs +129 -0
- package/test/cli-v47.test.mjs +98 -0
- package/test/closure-check.test.mjs +202 -0
- package/test/closure-import.test.mjs +263 -0
- package/test/collector-config.test.mjs +25 -0
- package/test/collector-registry.test.mjs +56 -0
- package/test/csv.test.mjs +19 -0
- package/test/db-annotations.test.mjs +186 -0
- package/test/db-v2.test.mjs +200 -0
- package/test/db-v4.test.mjs +178 -0
- package/test/experimental-collectors.test.mjs +103 -0
- package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
- package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
- package/test/fixtures/collectors/goose/usage.jsonl +2 -0
- package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
- package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
- package/test/import-budget.test.mjs +40 -0
- package/test/live.test.mjs +256 -0
- package/test/markdown-report.test.mjs +193 -0
- package/test/model-policy.test.mjs +34 -0
- package/test/model-strategy.test.mjs +116 -0
- package/test/model-usage.test.mjs +99 -0
- package/test/official-pricing.test.mjs +70 -0
- package/test/onboarding.test.mjs +55 -0
- package/test/privacy-check.test.mjs +33 -0
- package/test/review-closure-progress.test.mjs +99 -0
- package/test/roi-advisor.test.mjs +188 -0
- package/test/roi-evidence.test.mjs +48 -0
- package/test/roi-summary.test.mjs +101 -0
- package/test/savings-simulator.test.mjs +141 -0
- package/test/source-health.test.mjs +62 -0
- package/test/statusline.test.mjs +148 -0
- package/vite.config.js +23 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/* =============================================================
|
|
2
|
+
Charts — Trend, Donut, TopModels, Heatmap, Gauge, Stat
|
|
3
|
+
============================================================= */
|
|
4
|
+
|
|
5
|
+
import { Fragment, useEffect, useMemo, useRef } from 'react';
|
|
6
|
+
import * as echarts from 'echarts';
|
|
7
|
+
import { U } from '../shared/utils.js';
|
|
8
|
+
import { Delta } from './components-top.jsx';
|
|
9
|
+
|
|
10
|
+
// ───────────────────────────────────────────────────────────────
|
|
11
|
+
// ECharts wrapper
|
|
12
|
+
// ───────────────────────────────────────────────────────────────
|
|
13
|
+
function EChart({ option, height = 320, onEvents }) {
|
|
14
|
+
const ref = useRef(null);
|
|
15
|
+
const chartRef = useRef(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!ref.current) return;
|
|
19
|
+
chartRef.current = echarts.init(ref.current, null, { renderer: 'canvas' });
|
|
20
|
+
const onResize = () => chartRef.current?.resize();
|
|
21
|
+
window.addEventListener('resize', onResize);
|
|
22
|
+
if (onEvents) {
|
|
23
|
+
for (const [name, handler] of Object.entries(onEvents)) {
|
|
24
|
+
chartRef.current.on(name, handler);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return () => {
|
|
28
|
+
window.removeEventListener('resize', onResize);
|
|
29
|
+
chartRef.current?.dispose();
|
|
30
|
+
};
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (chartRef.current) chartRef.current.setOption(option, true);
|
|
35
|
+
}, [option]);
|
|
36
|
+
|
|
37
|
+
return <div ref={ref} style={{ width: '100%', height }} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ───────────────────────────────────────────────────────────────
|
|
41
|
+
// Trend chart — switchable bar/line/stacked + optional comparison
|
|
42
|
+
// ───────────────────────────────────────────────────────────────
|
|
43
|
+
const TREND_MODES = [
|
|
44
|
+
{ id: 'stacked', label: '堆叠' },
|
|
45
|
+
{ id: 'line', label: '折线' },
|
|
46
|
+
{ id: 'bar', label: '柱状' }
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function TrendChart({ rows, dates, sources, compareRows, compareDates, mode, onModeChange, totals, prevTotals, onExport, density }) {
|
|
50
|
+
// build series
|
|
51
|
+
const byKey = useMemo(() => {
|
|
52
|
+
const m = new Map();
|
|
53
|
+
for (const r of rows) m.set(`${r.usageDate}::${r.source}`, (m.get(`${r.usageDate}::${r.source}`) || 0) + r.totalTokens);
|
|
54
|
+
return m;
|
|
55
|
+
}, [rows]);
|
|
56
|
+
|
|
57
|
+
const compareByDate = useMemo(() => {
|
|
58
|
+
if (!compareRows) return null;
|
|
59
|
+
const m = new Map();
|
|
60
|
+
for (const r of compareRows) m.set(r.usageDate, (m.get(r.usageDate) || 0) + r.totalTokens);
|
|
61
|
+
return m;
|
|
62
|
+
}, [compareRows]);
|
|
63
|
+
|
|
64
|
+
const totalByDate = dates.map(d =>
|
|
65
|
+
sources.reduce((s, src) => s + (byKey.get(`${d}::${src}`) || 0), 0)
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const compareSeries = compareByDate
|
|
69
|
+
? compareDates.map(d => compareByDate.get(d) || 0)
|
|
70
|
+
: null;
|
|
71
|
+
|
|
72
|
+
// Trend rolling-avg (7-day) for line mode
|
|
73
|
+
const rolling = (() => {
|
|
74
|
+
const arr = [];
|
|
75
|
+
const win = Math.min(7, Math.max(2, Math.floor(dates.length / 8)));
|
|
76
|
+
for (let i = 0; i < totalByDate.length; i++) {
|
|
77
|
+
let sum = 0, count = 0;
|
|
78
|
+
for (let j = Math.max(0, i - win + 1); j <= i; j++) { sum += totalByDate[j]; count++; }
|
|
79
|
+
arr.push(count ? sum / count : 0);
|
|
80
|
+
}
|
|
81
|
+
return arr;
|
|
82
|
+
})();
|
|
83
|
+
|
|
84
|
+
// Build the series based on mode
|
|
85
|
+
const series = [];
|
|
86
|
+
const palette = sources.map(s => U.getSourceColor(s));
|
|
87
|
+
|
|
88
|
+
if (mode === 'stacked' || mode === 'bar') {
|
|
89
|
+
sources.forEach((src, i) => {
|
|
90
|
+
series.push({
|
|
91
|
+
name: src,
|
|
92
|
+
type: 'bar',
|
|
93
|
+
stack: mode === 'stacked' ? 'total' : undefined,
|
|
94
|
+
barMaxWidth: 24,
|
|
95
|
+
itemStyle: {
|
|
96
|
+
color: palette[i],
|
|
97
|
+
borderRadius: mode === 'stacked' && i === sources.length - 1 ? [4, 4, 0, 0] : (mode === 'bar' ? [3, 3, 0, 0] : 0)
|
|
98
|
+
},
|
|
99
|
+
emphasis: { focus: 'series' },
|
|
100
|
+
data: dates.map(d => byKey.get(`${d}::${src}`) || 0)
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
} else if (mode === 'line') {
|
|
104
|
+
sources.forEach((src, i) => {
|
|
105
|
+
series.push({
|
|
106
|
+
name: src,
|
|
107
|
+
type: 'line',
|
|
108
|
+
smooth: 0.3,
|
|
109
|
+
symbol: 'circle',
|
|
110
|
+
symbolSize: 4,
|
|
111
|
+
showSymbol: false,
|
|
112
|
+
lineStyle: { width: 2, color: palette[i] },
|
|
113
|
+
itemStyle: { color: palette[i] },
|
|
114
|
+
areaStyle: {
|
|
115
|
+
opacity: 0.08,
|
|
116
|
+
color: {
|
|
117
|
+
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
|
118
|
+
colorStops: [
|
|
119
|
+
{ offset: 0, color: palette[i] },
|
|
120
|
+
{ offset: 1, color: 'transparent' }
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
emphasis: { focus: 'series', lineStyle: { width: 3 } },
|
|
125
|
+
data: dates.map(d => byKey.get(`${d}::${src}`) || 0)
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Compare overlay (dashed total of previous period)
|
|
131
|
+
if (compareSeries) {
|
|
132
|
+
series.push({
|
|
133
|
+
name: '上一周期',
|
|
134
|
+
type: 'line',
|
|
135
|
+
smooth: 0.3,
|
|
136
|
+
symbol: 'none',
|
|
137
|
+
lineStyle: { width: 1.6, color: 'oklch(0.55 0.005 80)', type: 'dashed' },
|
|
138
|
+
itemStyle: { color: 'oklch(0.55 0.005 80)' },
|
|
139
|
+
data: dates.map((_, i) => compareSeries[i] || 0),
|
|
140
|
+
z: 5
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 7-day rolling baseline (subtle)
|
|
145
|
+
if (mode !== 'line' && dates.length > 10) {
|
|
146
|
+
series.push({
|
|
147
|
+
name: '7 日均线',
|
|
148
|
+
type: 'line',
|
|
149
|
+
smooth: 0.5,
|
|
150
|
+
symbol: 'none',
|
|
151
|
+
lineStyle: { width: 1.6, color: 'oklch(0.45 0.04 265)', type: [4, 4] },
|
|
152
|
+
data: rolling,
|
|
153
|
+
z: 4
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const option = {
|
|
158
|
+
backgroundColor: 'transparent',
|
|
159
|
+
animation: true,
|
|
160
|
+
animationDuration: 400,
|
|
161
|
+
tooltip: {
|
|
162
|
+
trigger: 'axis',
|
|
163
|
+
axisPointer: { type: 'shadow', shadowStyle: { color: 'oklch(0.95 0.004 80 / 0.6)' } },
|
|
164
|
+
backgroundColor: '#ffffff',
|
|
165
|
+
borderColor: 'oklch(0.92 0.004 80)',
|
|
166
|
+
borderWidth: 1,
|
|
167
|
+
padding: [10, 12],
|
|
168
|
+
textStyle: { color: 'oklch(0.18 0.005 80)', fontSize: 12 },
|
|
169
|
+
extraCssText: 'box-shadow: 0 8px 24px rgba(15,23,42,0.10); border-radius: 10px;',
|
|
170
|
+
formatter(params) {
|
|
171
|
+
const date = params[0]?.axisValue || '';
|
|
172
|
+
let total = 0;
|
|
173
|
+
for (const p of params) if (sources.includes(p.seriesName)) total += p.value || 0;
|
|
174
|
+
let html = `<div style="font-weight:600;margin-bottom:6px;color:oklch(0.40 0.005 80);font-size:11.5px;letter-spacing:.04em">${date}</div>`;
|
|
175
|
+
html += `<div style="font-size:16px;font-weight:600;margin-bottom:8px">${U.compactCN(total)} <span style="font-size:11px;color:oklch(0.55 0.005 80);font-weight:500"> tokens</span></div>`;
|
|
176
|
+
for (const p of params) {
|
|
177
|
+
html += `<div style="display:flex;align-items:center;gap:8px;margin-top:3px;font-size:12px">
|
|
178
|
+
<span style="width:8px;height:8px;border-radius:2px;background:${p.color};display:inline-block"></span>
|
|
179
|
+
<span style="color:oklch(0.45 0.005 80);flex:1">${p.seriesName}</span>
|
|
180
|
+
<span style="font-weight:600;margin-left:18px;font-variant-numeric:tabular-nums">${U.compactCN(p.value || 0)}</span>
|
|
181
|
+
</div>`;
|
|
182
|
+
}
|
|
183
|
+
return html;
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
legend: { show: false },
|
|
187
|
+
grid: { left: 8, right: 12, top: 16, bottom: density === 'compact' ? 26 : 40, containLabel: true },
|
|
188
|
+
xAxis: {
|
|
189
|
+
type: 'category',
|
|
190
|
+
data: dates,
|
|
191
|
+
boundaryGap: mode !== 'line',
|
|
192
|
+
axisLine: { lineStyle: { color: 'oklch(0.92 0.004 80)' } },
|
|
193
|
+
axisTick: { show: false },
|
|
194
|
+
axisLabel: {
|
|
195
|
+
color: 'oklch(0.55 0.005 80)',
|
|
196
|
+
fontSize: 10.5,
|
|
197
|
+
hideOverlap: true,
|
|
198
|
+
formatter: v => v.slice(5)
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
yAxis: {
|
|
202
|
+
type: 'value',
|
|
203
|
+
axisLabel: {
|
|
204
|
+
color: 'oklch(0.62 0.004 80)',
|
|
205
|
+
fontSize: 10.5,
|
|
206
|
+
formatter: v => U.compact(v)
|
|
207
|
+
},
|
|
208
|
+
splitLine: { lineStyle: { color: 'oklch(0.95 0.004 80)' } },
|
|
209
|
+
axisLine: { show: false },
|
|
210
|
+
axisTick: { show: false }
|
|
211
|
+
},
|
|
212
|
+
dataZoom: dates.length > 20 ? [
|
|
213
|
+
{ type: 'inside', start: 0, end: 100, zoomLock: false },
|
|
214
|
+
{
|
|
215
|
+
type: 'slider',
|
|
216
|
+
height: 18,
|
|
217
|
+
bottom: 4,
|
|
218
|
+
borderColor: 'transparent',
|
|
219
|
+
backgroundColor: 'oklch(0.97 0.004 80)',
|
|
220
|
+
fillerColor: 'oklch(0.92 0.02 265 / 0.5)',
|
|
221
|
+
handleStyle: { color: '#fff', borderColor: 'oklch(0.55 0.16 265)' },
|
|
222
|
+
moveHandleSize: 4,
|
|
223
|
+
textStyle: { color: 'oklch(0.55 0.005 80)', fontSize: 10 }
|
|
224
|
+
}
|
|
225
|
+
] : [],
|
|
226
|
+
series
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div className="panel">
|
|
231
|
+
<div className="panel-header">
|
|
232
|
+
<div>
|
|
233
|
+
<h2 className="panel-title">每日 Token 使用趋势</h2>
|
|
234
|
+
<p className="panel-sub">
|
|
235
|
+
{totals?.totalTokens != null && (
|
|
236
|
+
<>当前周期 <b style={{color:'var(--text)', fontWeight:600}}>{U.compactCN(totals.totalTokens)}</b> tokens · {dates.length} 天</>
|
|
237
|
+
)}
|
|
238
|
+
</p>
|
|
239
|
+
</div>
|
|
240
|
+
<div className="panel-actions">
|
|
241
|
+
<div className="panel-tabs">
|
|
242
|
+
{TREND_MODES.map(m => (
|
|
243
|
+
<button key={m.id} className={`tab ${mode === m.id ? 'active' : ''}`} onClick={() => onModeChange(m.id)}>
|
|
244
|
+
{m.label}
|
|
245
|
+
</button>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
<button className="btn btn-icon" onClick={onExport} title="导出 CSV">
|
|
249
|
+
<svg className="icon" viewBox="0 0 16 16" fill="none">
|
|
250
|
+
<path d="M8 2v8M5 7l3 3 3-3M3 13h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
251
|
+
</svg>
|
|
252
|
+
</button>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
<EChart option={option} height={320}/>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ───────────────────────────────────────────────────────────────
|
|
261
|
+
// Donut chart — source share
|
|
262
|
+
// ───────────────────────────────────────────────────────────────
|
|
263
|
+
function SourceDonut({ rows, sources, total, onFocusSource, focused }) {
|
|
264
|
+
const data = sources.map(src => {
|
|
265
|
+
let v = 0;
|
|
266
|
+
for (const r of rows) if (r.source === src) v += r.totalTokens;
|
|
267
|
+
return { name: src, value: v, color: U.getSourceColor(src) };
|
|
268
|
+
}).sort((a, b) => b.value - a.value);
|
|
269
|
+
|
|
270
|
+
const sum = data.reduce((s, d) => s + d.value, 0);
|
|
271
|
+
|
|
272
|
+
const option = {
|
|
273
|
+
backgroundColor: 'transparent',
|
|
274
|
+
tooltip: {
|
|
275
|
+
trigger: 'item',
|
|
276
|
+
backgroundColor: '#fff',
|
|
277
|
+
borderColor: 'oklch(0.92 0.004 80)',
|
|
278
|
+
borderWidth: 1,
|
|
279
|
+
textStyle: { color: 'oklch(0.18 0.005 80)', fontSize: 12 },
|
|
280
|
+
formatter: p => `<div style="font-weight:600;margin-bottom:4px">${p.name}</div>
|
|
281
|
+
<div style="font-size:14px;font-weight:600">${U.compactCN(p.value)} tokens</div>
|
|
282
|
+
<div style="font-size:11px;color:oklch(0.55 0.005 80)">${(p.percent || 0).toFixed(1)}%</div>`
|
|
283
|
+
},
|
|
284
|
+
series: [{
|
|
285
|
+
type: 'pie',
|
|
286
|
+
radius: ['62%', '92%'],
|
|
287
|
+
center: ['50%', '50%'],
|
|
288
|
+
avoidLabelOverlap: true,
|
|
289
|
+
label: { show: false },
|
|
290
|
+
labelLine: { show: false },
|
|
291
|
+
itemStyle: {
|
|
292
|
+
borderColor: '#fff',
|
|
293
|
+
borderWidth: 3
|
|
294
|
+
},
|
|
295
|
+
emphasis: { scaleSize: 4, itemStyle: { shadowBlur: 10, shadowColor: 'oklch(0 0 0 / 0.06)' } },
|
|
296
|
+
data: data.map(d => ({
|
|
297
|
+
name: d.name,
|
|
298
|
+
value: d.value,
|
|
299
|
+
itemStyle: { color: d.color, opacity: focused && focused !== d.name ? 0.25 : 1 }
|
|
300
|
+
}))
|
|
301
|
+
}]
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<div className="panel">
|
|
306
|
+
<div className="panel-header">
|
|
307
|
+
<div>
|
|
308
|
+
<h2 className="panel-title">来源占比</h2>
|
|
309
|
+
<p className="panel-sub">点击图例聚焦 · 顶部 1 项贡献 {data[0] && sum ? ((data[0].value / sum) * 100).toFixed(0) : 0}%</p>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
<div className="donut-row">
|
|
313
|
+
<div style={{position: 'relative', width: 200, height: 200, flexShrink: 0}}>
|
|
314
|
+
<EChart option={option} height={200}/>
|
|
315
|
+
<div style={{
|
|
316
|
+
position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
|
|
317
|
+
pointerEvents: 'none', textAlign: 'center'
|
|
318
|
+
}}>
|
|
319
|
+
<div>
|
|
320
|
+
<div style={{fontSize: 10.5, color: 'var(--muted)', letterSpacing: '0.08em', textTransform: 'uppercase'}}>合计</div>
|
|
321
|
+
<div style={{fontSize: 19, fontWeight: 600, fontVariantNumeric: 'tabular-nums', marginTop: 2}}>
|
|
322
|
+
{U.compactCN(sum)}
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
<div className="legend">
|
|
328
|
+
{data.map(d => (
|
|
329
|
+
<div key={d.name}
|
|
330
|
+
className={`legend-item ${focused && focused !== d.name ? 'dim' : ''}`}
|
|
331
|
+
onClick={() => onFocusSource(focused === d.name ? null : d.name)}>
|
|
332
|
+
<span className="legend-swatch" style={{background: d.color}}/>
|
|
333
|
+
<span className="legend-name">{d.name}</span>
|
|
334
|
+
<span className="legend-val">{U.compactCN(d.value)}</span>
|
|
335
|
+
<span className="legend-pct">{sum ? ((d.value / sum) * 100).toFixed(1) : 0}%</span>
|
|
336
|
+
</div>
|
|
337
|
+
))}
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ───────────────────────────────────────────────────────────────
|
|
345
|
+
// Top Models bar chart (HTML)
|
|
346
|
+
// ───────────────────────────────────────────────────────────────
|
|
347
|
+
function TopModels({ rows, onDrillModel }) {
|
|
348
|
+
const byModel = new Map();
|
|
349
|
+
for (const r of rows) {
|
|
350
|
+
if (!r.model) continue;
|
|
351
|
+
const k = r.model;
|
|
352
|
+
if (!byModel.has(k)) byModel.set(k, { model: k, source: r.source, total: 0, cost: 0, count: 0 });
|
|
353
|
+
const m = byModel.get(k);
|
|
354
|
+
m.total += r.totalTokens;
|
|
355
|
+
m.cost += r.costUSD;
|
|
356
|
+
m.count += 1;
|
|
357
|
+
}
|
|
358
|
+
const list = Array.from(byModel.values()).sort((a, b) => b.total - a.total).slice(0, 8);
|
|
359
|
+
const max = list[0]?.total || 1;
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
<div className="panel">
|
|
363
|
+
<div className="panel-header">
|
|
364
|
+
<div>
|
|
365
|
+
<h2 className="panel-title">Top 模型</h2>
|
|
366
|
+
<p className="panel-sub">按总 Token 排序 · {list.length} 个</p>
|
|
367
|
+
</div>
|
|
368
|
+
<span style={{fontSize: 11, color: 'var(--muted)'}}>Tokens · 官方价</span>
|
|
369
|
+
</div>
|
|
370
|
+
<div className="bars">
|
|
371
|
+
{list.length === 0 && <div className="empty">当前筛选下无数据</div>}
|
|
372
|
+
{list.map(m => (
|
|
373
|
+
<div key={m.model} className="bar-row" onClick={() => onDrillModel?.(m)}>
|
|
374
|
+
<div className="bar-label">
|
|
375
|
+
<div className="model">{m.model}</div>
|
|
376
|
+
<div className="meta">
|
|
377
|
+
<span className="tag">
|
|
378
|
+
<span className="tag-dot" style={{background: U.getSourceColor(m.source)}}/>
|
|
379
|
+
{m.source}
|
|
380
|
+
</span>
|
|
381
|
+
<span>{m.count} 条记录</span>
|
|
382
|
+
</div>
|
|
383
|
+
<div className="bar-track">
|
|
384
|
+
<div className="bar-fill"
|
|
385
|
+
style={{
|
|
386
|
+
width: `${(m.total / max) * 100}%`,
|
|
387
|
+
background: U.getSourceColor(m.source)
|
|
388
|
+
}}/>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
<div className="bar-value">
|
|
392
|
+
{U.compactCN(m.total)}
|
|
393
|
+
<small>{m.cost > 0 ? U.fmtUS.format(m.cost) : '—'}</small>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
))}
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ───────────────────────────────────────────────────────────────
|
|
403
|
+
// Heatmap (day × hour, synthetic from hourly pattern)
|
|
404
|
+
// ───────────────────────────────────────────────────────────────
|
|
405
|
+
function Heatmap({ rows, dates, hourlyPattern }) {
|
|
406
|
+
// For each date compute total, distribute across hours
|
|
407
|
+
const byDate = new Map();
|
|
408
|
+
for (const r of rows) byDate.set(r.usageDate, (byDate.get(r.usageDate) || 0) + r.totalTokens);
|
|
409
|
+
|
|
410
|
+
// Build a matrix: [date][hour]
|
|
411
|
+
const matrix = dates.map(d => {
|
|
412
|
+
const total = byDate.get(d) || 0;
|
|
413
|
+
return hourlyPattern.map(h => Math.round(total * h));
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const flat = matrix.flat();
|
|
417
|
+
const max = Math.max(...flat, 1);
|
|
418
|
+
|
|
419
|
+
const heatColor = (v) => {
|
|
420
|
+
const t = Math.pow(v / max, 0.6);
|
|
421
|
+
if (t < 0.02) return 'oklch(0.97 0.003 80)';
|
|
422
|
+
const lightness = 0.94 - t * 0.50;
|
|
423
|
+
const chroma = 0.02 + t * 0.16;
|
|
424
|
+
return `oklch(${lightness} ${chroma} 265)`;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// Limit dates to fit nicely (28 days max for readability)
|
|
428
|
+
const showDates = dates.slice(-28);
|
|
429
|
+
const showMatrix = matrix.slice(-28);
|
|
430
|
+
|
|
431
|
+
const HOURS_LABELS = ['0', '', '', '', '4', '', '', '', '8', '', '', '', '12', '', '', '', '16', '', '', '', '20', '', '', ''];
|
|
432
|
+
|
|
433
|
+
const rowCount = showDates.length;
|
|
434
|
+
const cellH = 18;
|
|
435
|
+
const gap = 2;
|
|
436
|
+
|
|
437
|
+
return (
|
|
438
|
+
<div className="panel">
|
|
439
|
+
<div className="panel-header">
|
|
440
|
+
<div>
|
|
441
|
+
<h2 className="panel-title">使用热力图</h2>
|
|
442
|
+
<p className="panel-sub">最近 {showDates.length} 天 × 24 小时分布 · 个人活跃时段</p>
|
|
443
|
+
</div>
|
|
444
|
+
<span className="heat-scale">
|
|
445
|
+
少
|
|
446
|
+
<span className="heat-scale-bar">
|
|
447
|
+
{Array.from({length: 8}, (_, i) => (
|
|
448
|
+
<span key={i} style={{background: heatColor((i / 7) * max)}}/>
|
|
449
|
+
))}
|
|
450
|
+
</span>
|
|
451
|
+
多
|
|
452
|
+
</span>
|
|
453
|
+
</div>
|
|
454
|
+
<div style={{
|
|
455
|
+
display: 'grid',
|
|
456
|
+
gridTemplateColumns: `52px repeat(24, 1fr)`,
|
|
457
|
+
gridTemplateRows: `18px repeat(${rowCount}, ${cellH}px)`,
|
|
458
|
+
columnGap: gap, rowGap: gap,
|
|
459
|
+
alignItems: 'center'
|
|
460
|
+
}}>
|
|
461
|
+
<div/>
|
|
462
|
+
{HOURS_LABELS.map((h, i) => (
|
|
463
|
+
<div key={`h-${i}`} className="heat-col-label" style={{gridRow: 1, gridColumn: i + 2}}>{h}</div>
|
|
464
|
+
))}
|
|
465
|
+
|
|
466
|
+
{showDates.map((d, di) => (
|
|
467
|
+
<Fragment key={d}>
|
|
468
|
+
<div className="heat-row-label" style={{gridRow: di + 2, gridColumn: 1}}>
|
|
469
|
+
{di % 3 === 0 || di === showDates.length - 1 ? d.slice(5) : ''}
|
|
470
|
+
</div>
|
|
471
|
+
{showMatrix[di].map((v, hi) => (
|
|
472
|
+
<div key={`${di}-${hi}`} className="heat-cell"
|
|
473
|
+
style={{
|
|
474
|
+
gridRow: di + 2, gridColumn: hi + 2,
|
|
475
|
+
background: heatColor(v),
|
|
476
|
+
height: cellH
|
|
477
|
+
}}
|
|
478
|
+
title={`${showDates[di]} ${String(hi).padStart(2,'0')}:00 · ${U.compactCN(v)} tokens`}/>
|
|
479
|
+
))}
|
|
480
|
+
</Fragment>
|
|
481
|
+
))}
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ───────────────────────────────────────────────────────────────
|
|
488
|
+
// Gauge / arc — cache hit rate
|
|
489
|
+
// ───────────────────────────────────────────────────────────────
|
|
490
|
+
function Gauge({ rate, cacheRead, cacheCreation, total, prevRate }) {
|
|
491
|
+
const r = Math.max(0, Math.min(100, rate));
|
|
492
|
+
const C = Math.PI * 70;
|
|
493
|
+
const dash = (r / 100) * C;
|
|
494
|
+
|
|
495
|
+
return (
|
|
496
|
+
<div className="panel">
|
|
497
|
+
<div className="panel-header">
|
|
498
|
+
<div>
|
|
499
|
+
<h2 className="panel-title">缓存命中率</h2>
|
|
500
|
+
<p className="panel-sub">cache_read / total</p>
|
|
501
|
+
</div>
|
|
502
|
+
<Delta value={U.deltaPct(rate, prevRate)} />
|
|
503
|
+
</div>
|
|
504
|
+
<div className="gauge">
|
|
505
|
+
<div className="gauge-wrap">
|
|
506
|
+
<svg viewBox="0 0 180 100" width="180" height="100">
|
|
507
|
+
<path d="M 10 90 A 80 80 0 0 1 170 90" stroke="oklch(0.95 0.004 80)" strokeWidth="14" fill="none" strokeLinecap="round"/>
|
|
508
|
+
<path
|
|
509
|
+
d="M 10 90 A 80 80 0 0 1 170 90"
|
|
510
|
+
stroke="url(#hitGrad)"
|
|
511
|
+
strokeWidth="14" fill="none" strokeLinecap="round"
|
|
512
|
+
strokeDasharray={`${dash} ${C}`}
|
|
513
|
+
style={{transition: 'stroke-dasharray 600ms cubic-bezier(0.22,1,0.36,1)'}}
|
|
514
|
+
/>
|
|
515
|
+
<defs>
|
|
516
|
+
<linearGradient id="hitGrad" x1="0" y1="0" x2="1" y2="0">
|
|
517
|
+
<stop offset="0%" stopColor="oklch(0.65 0.13 200)"/>
|
|
518
|
+
<stop offset="100%" stopColor="oklch(0.55 0.16 265)"/>
|
|
519
|
+
</linearGradient>
|
|
520
|
+
</defs>
|
|
521
|
+
</svg>
|
|
522
|
+
<div className="gauge-text">
|
|
523
|
+
<div>
|
|
524
|
+
<span className="gauge-num">{r.toFixed(1)}</span>
|
|
525
|
+
<span className="gauge-suffix">%</span>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
<div className="gauge-meta">
|
|
530
|
+
<span>读取 <b>{U.compactCN(cacheRead)}</b></span>
|
|
531
|
+
<span>创建 <b>{U.compactCN(cacheCreation)}</b></span>
|
|
532
|
+
</div>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ───────────────────────────────────────────────────────────────
|
|
539
|
+
// Growth stats panel — WoW / DoD
|
|
540
|
+
// ───────────────────────────────────────────────────────────────
|
|
541
|
+
function GrowthPanel({ totalsByDay }) {
|
|
542
|
+
const days = Array.from(totalsByDay.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
|
543
|
+
const values = days.map(d => d[1]);
|
|
544
|
+
const n = values.length;
|
|
545
|
+
|
|
546
|
+
const today = values[n - 1] || 0;
|
|
547
|
+
const yest = values[n - 2] || 0;
|
|
548
|
+
const dod = U.deltaPct(today, yest);
|
|
549
|
+
|
|
550
|
+
// last 7 vs prev 7
|
|
551
|
+
const last7 = values.slice(-7).reduce((s, v) => s + v, 0);
|
|
552
|
+
const prev7 = values.slice(-14, -7).reduce((s, v) => s + v, 0);
|
|
553
|
+
const wow = U.deltaPct(last7, prev7);
|
|
554
|
+
|
|
555
|
+
// last 30 vs prev 30
|
|
556
|
+
const last30 = values.slice(-30).reduce((s, v) => s + v, 0);
|
|
557
|
+
const prev30 = values.slice(-60, -30).reduce((s, v) => s + v, 0);
|
|
558
|
+
const mom = U.deltaPct(last30, prev30);
|
|
559
|
+
|
|
560
|
+
// best day
|
|
561
|
+
let bestIdx = 0;
|
|
562
|
+
values.forEach((v, i) => { if (v > values[bestIdx]) bestIdx = i; });
|
|
563
|
+
const bestDate = days[bestIdx]?.[0];
|
|
564
|
+
const bestVal = values[bestIdx];
|
|
565
|
+
|
|
566
|
+
// average daily
|
|
567
|
+
const avg = n ? Math.round(values.reduce((s, v) => s + v, 0) / n) : 0;
|
|
568
|
+
|
|
569
|
+
return (
|
|
570
|
+
<div className="panel">
|
|
571
|
+
<div className="panel-header">
|
|
572
|
+
<div>
|
|
573
|
+
<h2 className="panel-title">环比与趋势</h2>
|
|
574
|
+
<p className="panel-sub">基于当前筛选周期</p>
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
|
|
578
|
+
<GrowthStat label="日环比 DoD" value={dod} sub={`今日 ${U.compactCN(today)}`}/>
|
|
579
|
+
<GrowthStat label="周环比 WoW" value={wow} sub={`7 日 ${U.compactCN(last7)}`}/>
|
|
580
|
+
<GrowthStat label="月环比 MoM" value={mom} sub={`30 日 ${U.compactCN(last30)}`}/>
|
|
581
|
+
<GrowthStat label="日均" value={null} sub={U.compactCN(avg)} subUnit="tokens / day"/>
|
|
582
|
+
</div>
|
|
583
|
+
<div style={{marginTop: 14, padding: '10px 12px', background: 'var(--surface-2)',
|
|
584
|
+
borderRadius: 8, fontSize: 12, color: 'var(--text-2)',
|
|
585
|
+
display: 'flex', alignItems: 'center', gap: 8}}>
|
|
586
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{color: 'var(--c-amber)'}}>
|
|
587
|
+
<path d="M7 1.5l1.6 3.3 3.6.5-2.6 2.5.6 3.6L7 9.7l-3.2 1.7.6-3.6L1.8 5.3l3.6-.5L7 1.5z" fill="currentColor" opacity="0.85"/>
|
|
588
|
+
</svg>
|
|
589
|
+
<span>峰值 <b style={{fontWeight:600}}>{bestDate}</b> · {U.compactCN(bestVal)} tokens</span>
|
|
590
|
+
</div>
|
|
591
|
+
</div>
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function GrowthStat({ label, value, sub, subUnit }) {
|
|
596
|
+
return (
|
|
597
|
+
<div style={{
|
|
598
|
+
padding: '10px 12px',
|
|
599
|
+
background: 'var(--surface-2)',
|
|
600
|
+
border: '1px solid var(--border-2)',
|
|
601
|
+
borderRadius: 9,
|
|
602
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
|
|
603
|
+
whiteSpace: 'nowrap'
|
|
604
|
+
}}>
|
|
605
|
+
<div style={{minWidth: 0, overflow: 'hidden'}}>
|
|
606
|
+
<div style={{fontSize: 10.5, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', whiteSpace: 'nowrap'}}>{label}</div>
|
|
607
|
+
<div style={{fontSize: 11, color: 'var(--muted)', marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis'}}>
|
|
608
|
+
{value != null ? sub : (subUnit || '')}
|
|
609
|
+
</div>
|
|
610
|
+
</div>
|
|
611
|
+
<div style={{
|
|
612
|
+
fontSize: value != null ? 18 : 17, fontWeight: 600, fontVariantNumeric: 'tabular-nums',
|
|
613
|
+
color: value == null ? 'var(--text)' : (value > 0 ? 'var(--good)' : value < 0 ? 'var(--bad)' : 'var(--text)'),
|
|
614
|
+
whiteSpace: 'nowrap', flexShrink: 0
|
|
615
|
+
}}>
|
|
616
|
+
{value != null ? (value > 0 ? '+' : '') + value.toFixed(1) + '%' : sub}
|
|
617
|
+
</div>
|
|
618
|
+
</div>
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export { TrendChart, SourceDonut, TopModels, Heatmap, Gauge, GrowthPanel };
|