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.
Files changed (139) hide show
  1. package/.nvmrc +1 -0
  2. package/CHANGELOG.md +89 -0
  3. package/Dockerfile +17 -0
  4. package/LICENSE +22 -0
  5. package/NOTICE.md +21 -0
  6. package/PRIVACY.md +68 -0
  7. package/README.en.md +220 -0
  8. package/README.md +220 -0
  9. package/config/collectors.json +54 -0
  10. package/data/.gitkeep +1 -0
  11. package/docker-compose.yml +17 -0
  12. package/docs/assets/.gitkeep +1 -0
  13. package/docs/assets/token-studio-v44-dashboard.png +0 -0
  14. package/docs/assets/token-studio-v44-live.png +0 -0
  15. package/docs/assets/token-studio-v44-review-mobile.png +0 -0
  16. package/docs/assets/token-studio-v44-review.png +0 -0
  17. package/docs/assets/token-studio-v45-dashboard.png +0 -0
  18. package/docs/assets/token-studio-v45-live.png +0 -0
  19. package/docs/assets/token-studio-v45-review-mobile.png +0 -0
  20. package/docs/assets/token-studio-v45-review.png +0 -0
  21. package/docs/blog-case-study.md +34 -0
  22. package/docs/collector-support-matrix.md +65 -0
  23. package/docs/competitive-notes.md +87 -0
  24. package/docs/demo-data/README.md +12 -0
  25. package/docs/demo-data/token-studio-v2-demo.json +146 -0
  26. package/docs/demo-flow.md +39 -0
  27. package/docs/first-run.md +95 -0
  28. package/docs/local-collectors.md +49 -0
  29. package/docs/public-launch-checklist.md +45 -0
  30. package/docs/resume-bullets.md +7 -0
  31. package/docs/statusline.md +52 -0
  32. package/index.html +16 -0
  33. package/package.json +36 -0
  34. package/render.yaml +17 -0
  35. package/src/auto-attribution.mjs +396 -0
  36. package/src/ccusage-bridge.mjs +74 -0
  37. package/src/ccusage-import.mjs +415 -0
  38. package/src/cli.mjs +643 -0
  39. package/src/client/dashboard/App.jsx +1734 -0
  40. package/src/client/dashboard/annotation-presets.js +138 -0
  41. package/src/client/dashboard/attribution.js +328 -0
  42. package/src/client/dashboard/components-charts.jsx +622 -0
  43. package/src/client/dashboard/components-tables.jsx +1531 -0
  44. package/src/client/dashboard/components-top.jsx +307 -0
  45. package/src/client/dashboard/import-budget.js +41 -0
  46. package/src/client/dashboard/model-usage.js +108 -0
  47. package/src/client/dashboard/onboarding.js +80 -0
  48. package/src/client/dashboard/styles.css +2606 -0
  49. package/src/client/live/LiveApp.jsx +226 -0
  50. package/src/client/live/styles.css +446 -0
  51. package/src/client/main.jsx +20 -0
  52. package/src/client/review/ReviewApp.jsx +507 -0
  53. package/src/client/review/closure-progress.js +165 -0
  54. package/src/client/review/markdown-report.js +401 -0
  55. package/src/client/review/model-strategy.js +273 -0
  56. package/src/client/review/roi-advisor.js +255 -0
  57. package/src/client/review/roi-evidence.js +78 -0
  58. package/src/client/review/savings-simulator.js +252 -0
  59. package/src/client/review/sections-1.jsx +277 -0
  60. package/src/client/review/sections-2.jsx +927 -0
  61. package/src/client/review/styles.css +2321 -0
  62. package/src/client/review/utils.js +345 -0
  63. package/src/client/shared/utils.js +236 -0
  64. package/src/closure-check.mjs +537 -0
  65. package/src/closure-import.mjs +646 -0
  66. package/src/collect.mjs +247 -0
  67. package/src/collector-config.mjs +82 -0
  68. package/src/collector-registry.mjs +333 -0
  69. package/src/collectors/claude-code.mjs +355 -0
  70. package/src/collectors/codex.mjs +418 -0
  71. package/src/collectors/copilot.mjs +19 -0
  72. package/src/collectors/cursor.mjs +23 -0
  73. package/src/collectors/gemini.mjs +530 -0
  74. package/src/collectors/goose.mjs +15 -0
  75. package/src/collectors/hermes.mjs +206 -0
  76. package/src/collectors/kimi.mjs +15 -0
  77. package/src/collectors/openclaw.mjs +400 -0
  78. package/src/collectors/opencode.mjs +349 -0
  79. package/src/collectors/qwen.mjs +15 -0
  80. package/src/collectors/structured-usage.mjs +437 -0
  81. package/src/collectors/utils.mjs +93 -0
  82. package/src/db.mjs +1397 -0
  83. package/src/demo-seed.mjs +39 -0
  84. package/src/dev.mjs +43 -0
  85. package/src/live.mjs +428 -0
  86. package/src/model-policy.mjs +147 -0
  87. package/src/pricing.mjs +434 -0
  88. package/src/privacy-check.mjs +126 -0
  89. package/src/server.mjs +1240 -0
  90. package/src/source-health.mjs +195 -0
  91. package/src/statusline.mjs +156 -0
  92. package/src/terminal-report.mjs +245 -0
  93. package/src/update-pricing.mjs +8 -0
  94. package/test/annotation-presets.test.mjs +137 -0
  95. package/test/api-annotations.test.mjs +202 -0
  96. package/test/api-auto-attribution.test.mjs +169 -0
  97. package/test/api-source-health.test.mjs +109 -0
  98. package/test/api-v2.test.mjs +278 -0
  99. package/test/api-v43.test.mjs +151 -0
  100. package/test/api-v44.test.mjs +128 -0
  101. package/test/attribution-summary.test.mjs +164 -0
  102. package/test/auto-attribution.test.mjs +116 -0
  103. package/test/ccusage-bridge.test.mjs +36 -0
  104. package/test/ccusage-import.test.mjs +93 -0
  105. package/test/cli-v43.test.mjs +64 -0
  106. package/test/cli-v45.test.mjs +34 -0
  107. package/test/cli-v46.test.mjs +129 -0
  108. package/test/cli-v47.test.mjs +98 -0
  109. package/test/closure-check.test.mjs +202 -0
  110. package/test/closure-import.test.mjs +263 -0
  111. package/test/collector-config.test.mjs +25 -0
  112. package/test/collector-registry.test.mjs +56 -0
  113. package/test/csv.test.mjs +19 -0
  114. package/test/db-annotations.test.mjs +186 -0
  115. package/test/db-v2.test.mjs +200 -0
  116. package/test/db-v4.test.mjs +178 -0
  117. package/test/experimental-collectors.test.mjs +103 -0
  118. package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
  119. package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
  120. package/test/fixtures/collectors/goose/usage.jsonl +2 -0
  121. package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
  122. package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
  123. package/test/import-budget.test.mjs +40 -0
  124. package/test/live.test.mjs +256 -0
  125. package/test/markdown-report.test.mjs +193 -0
  126. package/test/model-policy.test.mjs +34 -0
  127. package/test/model-strategy.test.mjs +116 -0
  128. package/test/model-usage.test.mjs +99 -0
  129. package/test/official-pricing.test.mjs +70 -0
  130. package/test/onboarding.test.mjs +55 -0
  131. package/test/privacy-check.test.mjs +33 -0
  132. package/test/review-closure-progress.test.mjs +99 -0
  133. package/test/roi-advisor.test.mjs +188 -0
  134. package/test/roi-evidence.test.mjs +48 -0
  135. package/test/roi-summary.test.mjs +101 -0
  136. package/test/savings-simulator.test.mjs +141 -0
  137. package/test/source-health.test.mjs +62 -0
  138. package/test/statusline.test.mjs +148 -0
  139. 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 };