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,1734 @@
1
+ /* =============================================================
2
+ Main App — real data from /api/data
3
+ ============================================================= */
4
+
5
+ import { useCallback, useEffect, useMemo, useState } from 'react';
6
+ import { U } from '../shared/utils.js';
7
+ import {
8
+ attachAutoSuggestions,
9
+ autoAttributionIdentity,
10
+ buildAutoAttributionPlan
11
+ } from '../../auto-attribution.mjs';
12
+ import { Topbar, FilterBar, KPI } from './components-top.jsx';
13
+ import { TrendChart, SourceDonut, TopModels, Gauge, GrowthPanel, Heatmap } from './components-charts.jsx';
14
+ import { BatchAnnotationModal, TablePanel, DrillDrawer } from './components-tables.jsx';
15
+ import {
16
+ aggregateSessions,
17
+ buildAttributionStatusSummary,
18
+ buildPendingConfirmationSessions,
19
+ buildProjectRoiRows,
20
+ buildRiskDistribution,
21
+ buildWeeklyReview
22
+ } from './attribution.js';
23
+ import {
24
+ buildModelUsageRows,
25
+ filterSessionsByDashboardFilters,
26
+ sessionModel
27
+ } from './model-usage.js';
28
+ import {
29
+ BUDGET_TEMPLATES,
30
+ CCUSAGE_BRIDGE_REPORTS,
31
+ applyBudgetTemplate,
32
+ buildCcusageBridgeCommand,
33
+ defaultResetAnchor
34
+ } from './import-budget.js';
35
+ import { buildFirstRunState } from './onboarding.js';
36
+ import './styles.css';
37
+
38
+ function summarizeCollectOutput(stdout) {
39
+ return stdout
40
+ ? stdout.split('\n').filter(Boolean).slice(-5).join(' · ')
41
+ : '采集完成';
42
+ }
43
+
44
+ export function App() {
45
+ const [M, setM] = useState(null);
46
+ const [loadError, setLoadError] = useState(null);
47
+ const [refreshing, setRefreshing] = useState(false);
48
+ const [collecting, setCollecting] = useState(false);
49
+ const [collectStatus, setCollectStatus] = useState(null);
50
+ const [collectConfirmOpen, setCollectConfirmOpen] = useState(false);
51
+
52
+ // ───── Load data from API ─────
53
+ const loadData = useCallback(() => {
54
+ setRefreshing(true);
55
+ return fetch('/api/data')
56
+ .then(r => {
57
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
58
+ return r.json();
59
+ })
60
+ .then(data => {
61
+ // Assign colors to sources dynamically
62
+ const sourceNames = [...new Set(data.daily.map(r => r.source))];
63
+ const SOURCES = sourceNames.map((name, i) => ({
64
+ name,
65
+ color: U.getSourceColor(name)
66
+ }));
67
+
68
+ // Standard hourly pattern (normalized)
69
+ const rawHourly = [
70
+ 0.005, 0.003, 0.002, 0.001, 0.001, 0.003,
71
+ 0.008, 0.025, 0.045, 0.075, 0.092, 0.082,
72
+ 0.055, 0.078, 0.092, 0.088, 0.080, 0.060,
73
+ 0.045, 0.038, 0.045, 0.040, 0.025, 0.012
74
+ ];
75
+ const hsum = rawHourly.reduce((a, b) => a + b, 0);
76
+ const HOURLY = rawHourly.map(v => v / hsum);
77
+
78
+ setM({
79
+ ...data,
80
+ SOURCES,
81
+ HOURLY,
82
+ today: U.daysAgo(0)
83
+ });
84
+ setLoadError(null);
85
+ })
86
+ .catch(err => setLoadError(err.message))
87
+ .finally(() => setRefreshing(false));
88
+ }, []);
89
+
90
+ useEffect(() => { loadData(); }, [loadData]);
91
+
92
+ const syncCollectStatus = useCallback((options = {}) => {
93
+ return fetch('/api/collect/status')
94
+ .then(r => {
95
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
96
+ return r.json();
97
+ })
98
+ .then(data => {
99
+ if (data.status === 'running') {
100
+ setCollecting(true);
101
+ setCollectStatus({ type: 'running', message: data.message || '正在采集本机用量…' });
102
+ } else if (data.status === 'ok') {
103
+ setCollecting(false);
104
+ setCollectStatus({ type: 'ok', message: summarizeCollectOutput(data.stdout) });
105
+ if (options.refreshOnDone) loadData();
106
+ } else if (data.status === 'error') {
107
+ setCollecting(false);
108
+ setCollectStatus({ type: 'error', message: data.stderr || data.message || '采集失败' });
109
+ } else {
110
+ setCollecting(false);
111
+ }
112
+ return data;
113
+ });
114
+ }, [loadData]);
115
+
116
+ const waitForCollectDone = useCallback(async () => {
117
+ for (;;) {
118
+ await new Promise(resolve => setTimeout(resolve, 1500));
119
+ const data = await syncCollectStatus({ refreshOnDone: true });
120
+ if (data.status !== 'running') return data;
121
+ }
122
+ }, [syncCollectStatus]);
123
+
124
+ useEffect(() => {
125
+ let cancelled = false;
126
+ syncCollectStatus()
127
+ .then(data => {
128
+ if (!cancelled && data.status === 'running') waitForCollectDone();
129
+ })
130
+ .catch(() => {});
131
+ return () => { cancelled = true; };
132
+ }, [syncCollectStatus, waitForCollectDone]);
133
+
134
+ const runCollect = useCallback(() => {
135
+ setCollecting(true);
136
+ setCollectStatus({ type: 'running', message: '正在采集本机用量…' });
137
+ fetch('/api/collect', {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: '{}'
141
+ })
142
+ .then(async r => {
143
+ const data = await r.json().catch(() => ({}));
144
+ if (!r.ok && r.status !== 202) {
145
+ throw new Error(data.error || data.stderr || `HTTP ${r.status}`);
146
+ }
147
+ setCollectStatus({ type: 'running', message: data.message || '正在采集本机用量…' });
148
+ return waitForCollectDone();
149
+ })
150
+ .catch(err => {
151
+ setCollecting(false);
152
+ setCollectStatus({ type: 'error', message: err.message || '采集失败' });
153
+ });
154
+ }, [waitForCollectDone]);
155
+
156
+ const requestCollect = useCallback(() => {
157
+ setCollectConfirmOpen(true);
158
+ }, []);
159
+
160
+ const saveSessionAnnotation = useCallback((payload) => {
161
+ return fetch('/api/session-annotations', {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify(payload)
165
+ })
166
+ .then(async r => {
167
+ const data = await r.json().catch(() => ({}));
168
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
169
+ await loadData();
170
+ return data.annotation;
171
+ });
172
+ }, [loadData]);
173
+
174
+ const batchSaveSessionAnnotations = useCallback((payload) => {
175
+ return fetch('/api/session-annotations/batch', {
176
+ method: 'POST',
177
+ headers: { 'Content-Type': 'application/json' },
178
+ body: JSON.stringify(payload)
179
+ })
180
+ .then(async r => {
181
+ const data = await r.json().catch(() => ({}));
182
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
183
+ await loadData();
184
+ return data.updated;
185
+ });
186
+ }, [loadData]);
187
+
188
+ const deleteSessionAnnotation = useCallback((payload) => {
189
+ return fetch('/api/session-annotations', {
190
+ method: 'DELETE',
191
+ headers: { 'Content-Type': 'application/json' },
192
+ body: JSON.stringify(payload)
193
+ })
194
+ .then(async r => {
195
+ const data = await r.json().catch(() => ({}));
196
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
197
+ await loadData();
198
+ return data.deleted;
199
+ });
200
+ }, [loadData]);
201
+
202
+ const saveSessionOutput = useCallback((payload) => {
203
+ return fetch('/api/session-outputs', {
204
+ method: 'POST',
205
+ headers: { 'Content-Type': 'application/json' },
206
+ body: JSON.stringify(payload)
207
+ })
208
+ .then(async r => {
209
+ const data = await r.json().catch(() => ({}));
210
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
211
+ await loadData();
212
+ return data.output;
213
+ });
214
+ }, [loadData]);
215
+
216
+ const deleteSessionOutput = useCallback((payload) => {
217
+ return fetch('/api/session-outputs', {
218
+ method: 'DELETE',
219
+ headers: { 'Content-Type': 'application/json' },
220
+ body: JSON.stringify(payload)
221
+ })
222
+ .then(async r => {
223
+ const data = await r.json().catch(() => ({}));
224
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
225
+ await loadData();
226
+ return data.deleted;
227
+ });
228
+ }, [loadData]);
229
+
230
+ const saveProjectAliasRule = useCallback((payload) => {
231
+ return fetch('/api/project-alias-rules', {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify(payload)
235
+ })
236
+ .then(async r => {
237
+ const data = await r.json().catch(() => ({}));
238
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
239
+ await loadData();
240
+ return data.rule;
241
+ });
242
+ }, [loadData]);
243
+
244
+ const deleteProjectAliasRule = useCallback((payload) => {
245
+ return fetch('/api/project-alias-rules', {
246
+ method: 'DELETE',
247
+ headers: { 'Content-Type': 'application/json' },
248
+ body: JSON.stringify(payload)
249
+ })
250
+ .then(async r => {
251
+ const data = await r.json().catch(() => ({}));
252
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
253
+ await loadData();
254
+ return data.deleted;
255
+ });
256
+ }, [loadData]);
257
+
258
+ const createBackup = useCallback(() => {
259
+ return fetch('/api/backup', {
260
+ method: 'POST',
261
+ headers: { 'Content-Type': 'application/json' },
262
+ body: '{}'
263
+ })
264
+ .then(async r => {
265
+ const data = await r.json().catch(() => ({}));
266
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
267
+ return data.backup;
268
+ });
269
+ }, []);
270
+
271
+ const exportAnnotations = useCallback(() => {
272
+ return fetch('/api/export/annotations')
273
+ .then(async r => {
274
+ const data = await r.json().catch(() => ({}));
275
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
276
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' });
277
+ const url = URL.createObjectURL(blob);
278
+ const a = document.createElement('a');
279
+ a.href = url;
280
+ a.download = `token-studio-annotations-${U.daysAgo(0)}.json`;
281
+ a.click();
282
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
283
+ return data;
284
+ });
285
+ }, []);
286
+
287
+ const importAnnotations = useCallback((file) => {
288
+ return file.text()
289
+ .then(text => JSON.parse(text))
290
+ .then(payload => fetch('/api/import/annotations', {
291
+ method: 'POST',
292
+ headers: { 'Content-Type': 'application/json' },
293
+ body: JSON.stringify(payload)
294
+ }))
295
+ .then(async r => {
296
+ const data = await r.json().catch(() => ({}));
297
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
298
+ await loadData();
299
+ return data.imported;
300
+ });
301
+ }, [loadData]);
302
+
303
+ const importCcusageJson = useCallback((payload) => {
304
+ return fetch('/api/import/ccusage-json', {
305
+ method: 'POST',
306
+ headers: { 'Content-Type': 'application/json' },
307
+ body: JSON.stringify(payload)
308
+ })
309
+ .then(async r => {
310
+ const data = await r.json().catch(() => ({}));
311
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
312
+ if (payload.apply) await loadData();
313
+ return data;
314
+ });
315
+ }, [loadData]);
316
+
317
+ const saveBudgetProfile = useCallback((payload) => {
318
+ return fetch('/api/budget-profiles', {
319
+ method: 'POST',
320
+ headers: { 'Content-Type': 'application/json' },
321
+ body: JSON.stringify(payload)
322
+ })
323
+ .then(async r => {
324
+ const data = await r.json().catch(() => ({}));
325
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
326
+ await loadData();
327
+ return data.profile;
328
+ });
329
+ }, [loadData]);
330
+
331
+ const deleteBudgetProfile = useCallback((payload) => {
332
+ return fetch('/api/budget-profiles', {
333
+ method: 'DELETE',
334
+ headers: { 'Content-Type': 'application/json' },
335
+ body: JSON.stringify(payload)
336
+ })
337
+ .then(async r => {
338
+ const data = await r.json().catch(() => ({}));
339
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
340
+ await loadData();
341
+ return data.deleted;
342
+ });
343
+ }, [loadData]);
344
+
345
+ const applyAutoAttribution = useCallback((payload) => {
346
+ return fetch('/api/auto-attribution/apply', {
347
+ method: 'POST',
348
+ headers: { 'Content-Type': 'application/json' },
349
+ body: JSON.stringify(payload || {})
350
+ })
351
+ .then(async r => {
352
+ const data = await r.json().catch(() => ({}));
353
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
354
+ await loadData();
355
+ return data;
356
+ });
357
+ }, [loadData]);
358
+
359
+ const undoAutoAttribution = useCallback((payload) => {
360
+ return fetch('/api/auto-attribution/undo', {
361
+ method: 'POST',
362
+ headers: { 'Content-Type': 'application/json' },
363
+ body: JSON.stringify(payload || {})
364
+ })
365
+ .then(async r => {
366
+ const data = await r.json().catch(() => ({}));
367
+ if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
368
+ await loadData();
369
+ return data;
370
+ });
371
+ }, [loadData]);
372
+
373
+ // ───── Loading / error screens ─────
374
+ if (loadError) {
375
+ return (
376
+ <div style={{
377
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
378
+ justifyContent: 'center', height: '100vh', gap: 16,
379
+ color: 'var(--text-2)', fontFamily: 'var(--font)'
380
+ }}>
381
+ <svg width="40" height="40" viewBox="0 0 40 40" fill="none">
382
+ <circle cx="20" cy="20" r="18" stroke="oklch(0.65 0.16 25)" strokeWidth="2"/>
383
+ <path d="M20 12v10M20 28v2" stroke="oklch(0.65 0.16 25)" strokeWidth="2.5" strokeLinecap="round"/>
384
+ </svg>
385
+ <p style={{fontSize: 15, margin: 0}}>加载失败:{loadError}</p>
386
+ <button className="btn btn-primary" onClick={loadData}>重试</button>
387
+ </div>
388
+ );
389
+ }
390
+
391
+ if (!M) {
392
+ return (
393
+ <div style={{
394
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
395
+ justifyContent: 'center', height: '100vh', gap: 14,
396
+ color: 'var(--text-2)', fontFamily: 'var(--font)'
397
+ }}>
398
+ <svg className="spin" width="32" height="32" viewBox="0 0 32 32" fill="none">
399
+ <circle cx="16" cy="16" r="13" stroke="var(--c-indigo)" strokeWidth="2.5"
400
+ strokeDasharray="60" strokeDashoffset="20" strokeLinecap="round"/>
401
+ </svg>
402
+ <p style={{fontSize: 14, margin: 0}}>正在加载数据…</p>
403
+ </div>
404
+ );
405
+ }
406
+
407
+ return (
408
+ <>
409
+ <Dashboard
410
+ M={M}
411
+ refreshing={refreshing}
412
+ collecting={collecting}
413
+ collectStatus={collectStatus}
414
+ onRefresh={loadData}
415
+ onCollect={requestCollect}
416
+ onSaveAnnotation={saveSessionAnnotation}
417
+ onBatchSaveAnnotations={batchSaveSessionAnnotations}
418
+ onDeleteAnnotation={deleteSessionAnnotation}
419
+ onSaveOutput={saveSessionOutput}
420
+ onDeleteOutput={deleteSessionOutput}
421
+ onSaveProjectAliasRule={saveProjectAliasRule}
422
+ onDeleteProjectAliasRule={deleteProjectAliasRule}
423
+ onCreateBackup={createBackup}
424
+ onExportAnnotations={exportAnnotations}
425
+ onImportAnnotations={importAnnotations}
426
+ onImportCcusageJson={importCcusageJson}
427
+ onSaveBudgetProfile={saveBudgetProfile}
428
+ onDeleteBudgetProfile={deleteBudgetProfile}
429
+ onApplyAutoAttribution={applyAutoAttribution}
430
+ onUndoAutoAttribution={undoAutoAttribution} />
431
+ {collectConfirmOpen && (
432
+ <CollectConfirmModal
433
+ busy={collecting}
434
+ onClose={() => setCollectConfirmOpen(false)}
435
+ onConfirm={() => {
436
+ setCollectConfirmOpen(false);
437
+ runCollect();
438
+ }} />
439
+ )}
440
+ </>
441
+ );
442
+ }
443
+
444
+ /* =============================================================
445
+ Dashboard (extracted so App stays clean)
446
+ ============================================================= */
447
+ function Dashboard({
448
+ M,
449
+ refreshing,
450
+ collecting,
451
+ collectStatus,
452
+ onRefresh,
453
+ onCollect,
454
+ onSaveAnnotation,
455
+ onBatchSaveAnnotations,
456
+ onDeleteAnnotation,
457
+ onSaveOutput,
458
+ onDeleteOutput,
459
+ onSaveProjectAliasRule,
460
+ onDeleteProjectAliasRule,
461
+ onCreateBackup,
462
+ onExportAnnotations,
463
+ onImportAnnotations,
464
+ onImportCcusageJson,
465
+ onSaveBudgetProfile,
466
+ onDeleteBudgetProfile,
467
+ onApplyAutoAttribution,
468
+ onUndoAutoAttribution
469
+ }) {
470
+ // ───── Filter state ─────
471
+ const [filters, setFilters] = useState(() => ({
472
+ rangeId: '30d',
473
+ startDate: U.daysAgo(29),
474
+ endDate: U.daysAgo(0),
475
+ sources: new Set(),
476
+ devices: new Set(),
477
+ models: new Set(),
478
+ compare: true
479
+ }));
480
+
481
+ const [trendMode, setTrendMode] = useState('stacked');
482
+ const [drill, setDrill] = useState(null);
483
+ const [focusedSource, setFocusedSource] = useState(null);
484
+ const [quickAttributionOpen, setQuickAttributionOpen] = useState(false);
485
+ const [quickAttributionBusy, setQuickAttributionBusy] = useState(false);
486
+ const [quickAttributionError, setQuickAttributionError] = useState(null);
487
+ const [autoAttributionBusy, setAutoAttributionBusy] = useState(false);
488
+ const [autoAttributionMessage, setAutoAttributionMessage] = useState(null);
489
+ const [lastAutoRunId, setLastAutoRunId] = useState(null);
490
+ const [importBudgetOpen, setImportBudgetOpen] = useState(false);
491
+
492
+ // Build option lists
493
+ const allSources = useMemo(() => Array.from(new Set(M.daily.map(r => r.source))), [M.daily]);
494
+ const allDevices = useMemo(() => Array.from(new Set(M.daily.map(r => r.device))), [M.daily]);
495
+ const allModels = useMemo(() => Array.from(new Set([
496
+ ...M.daily.map(r => r.model),
497
+ ...M.sessions.map(sessionModel)
498
+ ])).filter(Boolean), [M.daily, M.sessions]);
499
+ const availableRange = useMemo(() => {
500
+ const dates = M.daily.map(r => r.usageDate).filter(Boolean).sort();
501
+ return {
502
+ startDate: dates[0] || U.daysAgo(0),
503
+ endDate: dates[dates.length - 1] || U.daysAgo(0)
504
+ };
505
+ }, [M.daily]);
506
+ const taskTypes = M.meta?.taskTypes || ['未分类', '功能开发', '问题修复', '代码审查', '技术调研', '内容创作', '运维配置', '其他'];
507
+ const outputStatuses = M.meta?.outputStatuses || ['未标注', '进行中', '已完成', '已发布', '已废弃'];
508
+ const workPurposes = M.meta?.workPurposes || ['未说明', '需求澄清', '方案设计', '功能开发', '调试修复', '测试验证', '代码审查', '技术调研', '文档内容', '部署运维', '上下文整理', '其他'];
509
+ const workStages = M.meta?.workStages || ['未说明', '探索', '实现', '验证', '发布', '维护'];
510
+ const valueLevels = M.meta?.valueLevels || ['未评估', '低', '中', '高', '关键'];
511
+ const outputTypes = M.meta?.outputTypes || ['未分类', 'PR', 'commit', '文章', '部署', '文档', '截图', '其他'];
512
+ const effectiveFilters = useMemo(() => {
513
+ if (!focusedSource) return filters;
514
+ return { ...filters, sources: new Set([focusedSource]) };
515
+ }, [filters, focusedSource]);
516
+ const modelScopeFilters = useMemo(() => ({
517
+ ...effectiveFilters,
518
+ models: new Set()
519
+ }), [effectiveFilters]);
520
+
521
+ // ───── Filtered data ─────
522
+ const filtered = useMemo(() => {
523
+ return U.filterDaily(M.daily, effectiveFilters);
524
+ }, [effectiveFilters, M.daily]);
525
+
526
+ const totals = useMemo(() => U.aggregateTotals(filtered), [filtered]);
527
+
528
+ const dates = useMemo(() => U.rangeDates(filters.startDate, filters.endDate), [filters.startDate, filters.endDate]);
529
+ const presentSources = useMemo(() => {
530
+ const set = effectiveFilters.sources.size ? effectiveFilters.sources : new Set(allSources);
531
+ return Array.from(set);
532
+ }, [effectiveFilters.sources, allSources]);
533
+
534
+ // ───── Comparison period ─────
535
+ const compareData = useMemo(() => {
536
+ if (!effectiveFilters.compare) return { rows: null, dates: null, totals: null };
537
+ const days = dates.length;
538
+ const endStr = U.addDays(filters.startDate, -1);
539
+ const startStr = U.addDays(endStr, -(days - 1));
540
+ const rows = U.filterDaily(M.daily, { ...effectiveFilters, startDate: startStr, endDate: endStr });
541
+ const cDates = U.rangeDates(startStr, endStr);
542
+ return { rows, dates: cDates, totals: U.aggregateTotals(rows) };
543
+ }, [effectiveFilters, filters.startDate, dates.length, M.daily]);
544
+
545
+ // ───── Sparklines ─────
546
+ const dailyTotalsByDay = useMemo(() => {
547
+ const m = new Map();
548
+ for (const r of filtered) m.set(r.usageDate, (m.get(r.usageDate) || 0) + r.totalTokens);
549
+ return m;
550
+ }, [filtered]);
551
+
552
+ const sparkValues = useMemo(() => dates.map(d => dailyTotalsByDay.get(d) || 0), [dates, dailyTotalsByDay]);
553
+
554
+ const sparkBy = useMemo(() => (key) => {
555
+ const m = new Map();
556
+ for (const r of filtered) m.set(r.usageDate, (m.get(r.usageDate) || 0) + (r[key] || 0));
557
+ return dates.map(d => m.get(d) || 0);
558
+ }, [filtered, dates]);
559
+
560
+ // ───── Sessions filtered ─────
561
+ const filteredSessions = useMemo(() => {
562
+ return filterSessionsByDashboardFilters(M.sessions, effectiveFilters)
563
+ .sort((a, b) => b.totalTokens - a.totalTokens);
564
+ }, [effectiveFilters, M.sessions]);
565
+
566
+ const modelScopeDaily = useMemo(() => U.filterDaily(M.daily, modelScopeFilters), [M.daily, modelScopeFilters]);
567
+ const modelScopeSessions = useMemo(() => filterSessionsByDashboardFilters(M.sessions, modelScopeFilters), [M.sessions, modelScopeFilters]);
568
+ const modelUsageRows = useMemo(() => buildModelUsageRows(modelScopeDaily, modelScopeSessions), [modelScopeDaily, modelScopeSessions]);
569
+
570
+ const autoAttributionPlan = useMemo(() => buildAutoAttributionPlan({
571
+ sessions: filteredSessions,
572
+ projectAliasRules: M.meta?.projectAliasRules || []
573
+ }), [filteredSessions, M.meta?.projectAliasRules]);
574
+ const filteredSessionsWithAutoSuggestions = useMemo(() =>
575
+ attachAutoSuggestions(filteredSessions, autoAttributionPlan.suggestions)
576
+ , [filteredSessions, autoAttributionPlan]);
577
+
578
+ const sessionTotals = useMemo(() => aggregateSessions(filteredSessions), [filteredSessions]);
579
+ const attributionStatusSummary = useMemo(() => buildAttributionStatusSummary(filteredSessions), [filteredSessions]);
580
+ const riskDistribution = useMemo(() => buildRiskDistribution(filteredSessions), [filteredSessions]);
581
+ const projectRoiRows = useMemo(() => buildProjectRoiRows(filteredSessions), [filteredSessions]);
582
+ const weeklyReview = useMemo(() => buildWeeklyReview(M.sessions, { today: M.today }), [M.sessions, M.today]);
583
+ const unattributedSessions = useMemo(() =>
584
+ buildPendingConfirmationSessions(filteredSessionsWithAutoSuggestions)
585
+ , [filteredSessionsWithAutoSuggestions]);
586
+ const firstRunState = useMemo(() => buildFirstRunState(M), [M]);
587
+
588
+ const filteredRuns = useMemo(() => {
589
+ return M.runs.filter(r =>
590
+ (effectiveFilters.sources.size === 0 || effectiveFilters.sources.has(r.source)) &&
591
+ (effectiveFilters.devices.size === 0 || effectiveFilters.devices.has(r.device))
592
+ );
593
+ }, [effectiveFilters.sources, effectiveFilters.devices, M.runs]);
594
+
595
+ const toggleModelFilter = useCallback((model) => {
596
+ setFilters(prev => {
597
+ const next = new Set(prev.models);
598
+ if (next.has(model)) next.delete(model); else next.add(model);
599
+ return { ...prev, models: next };
600
+ });
601
+ }, []);
602
+
603
+ const clearModelFilter = useCallback(() => {
604
+ setFilters(prev => ({ ...prev, models: new Set() }));
605
+ }, []);
606
+
607
+ const saveQuickAttribution = async (values) => {
608
+ setQuickAttributionBusy(true);
609
+ setQuickAttributionError(null);
610
+ try {
611
+ const payloadValues = {};
612
+ if (values.projectAlias) payloadValues.projectAlias = values.projectAlias;
613
+ if (values.taskType) payloadValues.taskType = values.taskType;
614
+ if (values.outputStatus) payloadValues.outputStatus = values.outputStatus;
615
+ if (values.workPurpose) payloadValues.workPurpose = values.workPurpose;
616
+ if (values.workStage) payloadValues.workStage = values.workStage;
617
+ if (values.valueLevel) payloadValues.valueLevel = values.valueLevel;
618
+ if (values.note) payloadValues.note = values.note;
619
+ if (Object.keys(payloadValues).length === 0) throw new Error('至少选择一个要批量更新的字段');
620
+ await onBatchSaveAnnotations({
621
+ sessions: unattributedSessions.map(sessionIdentity),
622
+ values: payloadValues
623
+ });
624
+ setQuickAttributionOpen(false);
625
+ } catch (error) {
626
+ setQuickAttributionError(error.message || '批量归因失败');
627
+ } finally {
628
+ setQuickAttributionBusy(false);
629
+ }
630
+ };
631
+
632
+ const applyHighConfidenceAutoAttribution = async () => {
633
+ setAutoAttributionBusy(true);
634
+ setAutoAttributionMessage(null);
635
+ try {
636
+ const rows = autoAttributionPlan.suggestions.filter(item => item.canApply);
637
+ const result = await onApplyAutoAttribution({
638
+ threshold: autoAttributionPlan.threshold,
639
+ sessions: rows.map(autoAttributionIdentity)
640
+ });
641
+ setLastAutoRunId(result.runId || null);
642
+ setAutoAttributionMessage({ type: 'ok', text: `已自动归因 ${result.applied || 0} 个 session` });
643
+ } catch (error) {
644
+ setAutoAttributionMessage({ type: 'error', text: error.message || '自动归因失败' });
645
+ } finally {
646
+ setAutoAttributionBusy(false);
647
+ }
648
+ };
649
+
650
+ const undoLastAutoAttribution = async () => {
651
+ if (!lastAutoRunId) return;
652
+ setAutoAttributionBusy(true);
653
+ setAutoAttributionMessage(null);
654
+ try {
655
+ const result = await onUndoAutoAttribution({ runId: lastAutoRunId });
656
+ setLastAutoRunId(null);
657
+ setAutoAttributionMessage({ type: 'ok', text: `已撤销 ${result.deleted || 0} 个自动归因` });
658
+ } catch (error) {
659
+ setAutoAttributionMessage({ type: 'error', text: error.message || '撤销失败' });
660
+ } finally {
661
+ setAutoAttributionBusy(false);
662
+ }
663
+ };
664
+
665
+ // ───── Export ─────
666
+ const onExportAll = () => {
667
+ U.downloadCSV(`tokens-daily-${filters.startDate}-${filters.endDate}.csv`, filtered, [
668
+ { title: 'date', field: 'usageDate' },
669
+ { title: 'source', field: 'source' },
670
+ { title: 'device', field: 'device' },
671
+ { title: 'model', field: 'model' },
672
+ { title: 'input', field: 'inputTokens' },
673
+ { title: 'output', field: 'outputTokens' },
674
+ { title: 'cache_read', field: 'cacheReadTokens' },
675
+ { title: 'cache_creation', field: 'cacheCreationTokens' },
676
+ { title: 'reasoning', field: 'reasoningOutputTokens' },
677
+ { title: 'total', field: 'totalTokens' },
678
+ { title: 'official_price_usd', field: 'costUSD' },
679
+ { title: 'pricing_status', field: 'pricingStatus' },
680
+ { title: 'pricing_model', field: 'pricingModel' },
681
+ { title: 'pricing_source', field: 'pricingSource' }
682
+ ]);
683
+ };
684
+
685
+ const onExportTrend = () => {
686
+ const rows = dates.map(d => {
687
+ const r = { date: d };
688
+ for (const s of presentSources) {
689
+ let v = 0;
690
+ for (const x of filtered) if (x.usageDate === d && x.source === s) v += x.totalTokens;
691
+ r[s] = v;
692
+ }
693
+ return r;
694
+ });
695
+ U.downloadCSV(`trend-${filters.startDate}-${filters.endDate}.csv`,
696
+ rows,
697
+ [{ title: 'date', field: 'date' }, ...presentSources.map(s => ({ title: s, field: s }))]
698
+ );
699
+ };
700
+
701
+ const lastSync = M.runs[0] ? U.formatTs(M.runs[0].collectedAt.replace(' ', 'T')) : '—';
702
+
703
+ return (
704
+ <div className="app">
705
+ <Topbar
706
+ lastSync={lastSync}
707
+ onRefresh={onRefresh}
708
+ refreshing={refreshing}
709
+ onCollect={onCollect}
710
+ collecting={collecting}
711
+ collectStatus={collectStatus}
712
+ demoMode={M.meta?.demoMode}
713
+ onOpenImportBudget={() => setImportBudgetOpen(true)} />
714
+
715
+ <FilterBar
716
+ f={filters}
717
+ setF={setFilters}
718
+ allSources={allSources}
719
+ allDevices={allDevices}
720
+ allModels={allModels}
721
+ availableRange={availableRange}
722
+ onExport={onExportAll} />
723
+
724
+ {focusedSource && (
725
+ <div style={{
726
+ margin: '0 0 12px',
727
+ padding: '10px 14px',
728
+ background: 'oklch(0.97 0.02 265)',
729
+ border: '1px solid oklch(0.85 0.04 265)',
730
+ borderRadius: 10,
731
+ display: 'flex', alignItems: 'center', gap: 10,
732
+ fontSize: 12.5
733
+ }}>
734
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
735
+ <path d="M3 7l3 3 5-6" stroke="var(--c-indigo)" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
736
+ </svg>
737
+ <span>聚焦中:<b style={{ color: 'var(--c-indigo)' }}>{focusedSource}</b> · 所有图表已联动</span>
738
+ <button className="btn" style={{ marginLeft: 'auto', height: 24, fontSize: 11.5 }}
739
+ onClick={() => setFocusedSource(null)}>取消聚焦</button>
740
+ </div>
741
+ )}
742
+
743
+ <FirstRunPanel
744
+ state={firstRunState}
745
+ onOpenImportBudget={() => setImportBudgetOpen(true)} />
746
+ <SourceHealthPanel
747
+ rows={M.meta?.sourceHealth || []}
748
+ onOpenImportBudget={() => setImportBudgetOpen(true)} />
749
+
750
+ {/* KPI row */}
751
+ <div className="kpi-row">
752
+ <KPI label="总 Token" value={U.compactCN(totals.totalTokens)}
753
+ sub="vs 上周期"
754
+ delta={U.deltaPct(totals.totalTokens, compareData.totals?.totalTokens)}
755
+ sparkValues={sparkValues} sparkColor="oklch(0.55 0.16 265)" />
756
+ <KPI label="Input" value={U.compactCN(totals.inputTokens)}
757
+ sub="输入"
758
+ delta={U.deltaPct(totals.inputTokens, compareData.totals?.inputTokens)}
759
+ sparkValues={sparkBy('inputTokens')} sparkColor="oklch(0.62 0.13 240)" />
760
+ <KPI label="Output" value={U.compactCN(totals.outputTokens)}
761
+ sub="生成"
762
+ delta={U.deltaPct(totals.outputTokens, compareData.totals?.outputTokens)}
763
+ sparkValues={sparkBy('outputTokens')} sparkColor="oklch(0.60 0.15 295)" />
764
+ <KPI label="Cache" value={U.compactCN(totals.cacheTokens)}
765
+ sub={`命中 ${totals.cacheHitRate.toFixed(0)}%`}
766
+ delta={U.deltaPct(totals.cacheTokens, compareData.totals?.cacheTokens)}
767
+ sparkValues={sparkBy('cacheReadTokens')} sparkColor="oklch(0.65 0.11 200)" />
768
+ <KPI label="Reasoning" value={U.compactCN(totals.reasoningTokens)}
769
+ sub="推理"
770
+ delta={U.deltaPct(totals.reasoningTokens, compareData.totals?.reasoningTokens)}
771
+ sparkValues={sparkBy('reasoningOutputTokens')} sparkColor="oklch(0.65 0.12 150)" />
772
+ <KPI label="官方价账单" value={U.fmtUS.format(totals.costUSD)}
773
+ sub="按官网单价"
774
+ delta={U.deltaPct(totals.costUSD, compareData.totals?.costUSD)}
775
+ sparkValues={sparkBy('costUSD')} sparkColor="oklch(0.72 0.14 75)" />
776
+ </div>
777
+
778
+ <OfficialPricingNotice meta={M.meta?.officialPricing} visibleCostUSD={totals.costUSD} />
779
+ <ModelUsageOverview
780
+ rows={modelUsageRows}
781
+ selectedModels={filters.models}
782
+ onToggleModel={toggleModelFilter}
783
+ onClearModels={clearModelFilter} />
784
+ <AutoAttributionPanel
785
+ plan={autoAttributionPlan}
786
+ busy={autoAttributionBusy}
787
+ message={autoAttributionMessage}
788
+ lastRunId={lastAutoRunId}
789
+ onApply={applyHighConfidenceAutoAttribution}
790
+ onUndo={undoLastAutoAttribution} />
791
+ <AttributionOverview
792
+ rows={attributionStatusSummary}
793
+ totalTokens={sessionTotals.totalTokens}
794
+ totalSessions={sessionTotals.sessionCount}
795
+ onQuickAttribute={() => {
796
+ setQuickAttributionError(null);
797
+ setQuickAttributionOpen(true);
798
+ }} />
799
+ <RoiReview
800
+ riskRows={riskDistribution}
801
+ projectRows={projectRoiRows}
802
+ weeklyReview={weeklyReview}
803
+ totalTokens={sessionTotals.totalTokens} />
804
+
805
+ {/* Charts grid */}
806
+ <div className="grid">
807
+ <div className="col-8">
808
+ <TrendChart
809
+ rows={filtered}
810
+ dates={dates}
811
+ sources={presentSources}
812
+ compareRows={compareData.rows}
813
+ compareDates={compareData.dates}
814
+ mode={trendMode}
815
+ onModeChange={setTrendMode}
816
+ totals={totals}
817
+ onExport={onExportTrend} />
818
+ </div>
819
+ <div className="col-4">
820
+ <SourceDonut
821
+ rows={filtered}
822
+ sources={Array.from(new Set(filtered.map(r => r.source)))}
823
+ total={totals.totalTokens}
824
+ focused={focusedSource}
825
+ onFocusSource={setFocusedSource} />
826
+ </div>
827
+
828
+ <div className="col-6">
829
+ <TopModels rows={filtered} onDrillModel={r => setDrill({ kind: 'model', row: r })} />
830
+ </div>
831
+ <div className="col-3" style={{ gridColumn: 'span 3' }}>
832
+ <Gauge
833
+ rate={totals.cacheHitRate}
834
+ cacheRead={totals.cacheReadTokens}
835
+ cacheCreation={totals.cacheCreationTokens}
836
+ total={totals.totalTokens}
837
+ prevRate={compareData.totals?.cacheHitRate} />
838
+ </div>
839
+ <div className="col-3" style={{ gridColumn: 'span 3' }}>
840
+ <GrowthPanel totalsByDay={dailyTotalsByDay} />
841
+ </div>
842
+
843
+ <div className="col-12">
844
+ <Heatmap rows={filtered} dates={dates} hourlyPattern={M.HOURLY} />
845
+ </div>
846
+
847
+ <div className="col-12">
848
+ <TablePanel
849
+ daily={filtered}
850
+ sessions={filteredSessionsWithAutoSuggestions}
851
+ unattributedSessions={unattributedSessions}
852
+ runs={filteredRuns}
853
+ taskTypes={taskTypes}
854
+ outputStatuses={outputStatuses}
855
+ workPurposes={workPurposes}
856
+ workStages={workStages}
857
+ valueLevels={valueLevels}
858
+ outputTypes={outputTypes}
859
+ projectAliasRules={M.meta?.projectAliasRules || []}
860
+ projectAliasMatchTypes={M.meta?.projectAliasMatchTypes || ['prefix']}
861
+ sources={presentSources}
862
+ totalTokens={totals.totalTokens}
863
+ sessionTotalTokens={sessionTotals.totalTokens}
864
+ onSaveAnnotation={onSaveAnnotation}
865
+ onBatchSaveAnnotations={onBatchSaveAnnotations}
866
+ onDeleteAnnotation={onDeleteAnnotation}
867
+ onSaveOutput={onSaveOutput}
868
+ onDeleteOutput={onDeleteOutput}
869
+ onSaveProjectAliasRule={onSaveProjectAliasRule}
870
+ onDeleteProjectAliasRule={onDeleteProjectAliasRule}
871
+ onCreateBackup={onCreateBackup}
872
+ onExportAnnotations={onExportAnnotations}
873
+ onImportAnnotations={onImportAnnotations}
874
+ onDrill={setDrill} />
875
+ </div>
876
+ </div>
877
+
878
+ <DrillDrawer drill={drill} daily={M.daily} onClose={() => setDrill(null)} />
879
+ {quickAttributionOpen && (
880
+ <BatchAnnotationModal
881
+ count={unattributedSessions.length}
882
+ taskTypes={taskTypes}
883
+ outputStatuses={outputStatuses}
884
+ workPurposes={workPurposes}
885
+ workStages={workStages}
886
+ valueLevels={valueLevels}
887
+ busy={quickAttributionBusy}
888
+ error={quickAttributionError}
889
+ onSave={saveQuickAttribution}
890
+ onClose={() => {
891
+ if (!quickAttributionBusy) {
892
+ setQuickAttributionOpen(false);
893
+ setQuickAttributionError(null);
894
+ }
895
+ }} />
896
+ )}
897
+ {importBudgetOpen && (
898
+ <ImportBudgetModal
899
+ sources={allSources}
900
+ budgetProfiles={M.budgetProfiles || []}
901
+ onImportCcusageJson={onImportCcusageJson}
902
+ onSaveBudgetProfile={onSaveBudgetProfile}
903
+ onDeleteBudgetProfile={onDeleteBudgetProfile}
904
+ onClose={() => setImportBudgetOpen(false)} />
905
+ )}
906
+ </div>
907
+ );
908
+ }
909
+
910
+ function FirstRunPanel({ state, onOpenImportBudget }) {
911
+ if (!state?.shouldShow) return null;
912
+ const primaryNotice = state.notices[0] || null;
913
+
914
+ const runAction = (notice) => {
915
+ if (!notice) return;
916
+ if (notice.id === 'no-data') {
917
+ onOpenImportBudget();
918
+ } else if (notice.id === 'no-actions') {
919
+ window.location.href = '/review';
920
+ } else if (notice.id === 'budget-no-live-events') {
921
+ window.location.href = '/live';
922
+ }
923
+ };
924
+
925
+ return (
926
+ <section className="first-run-panel" aria-label="首次使用引导">
927
+ <div className="first-run-main">
928
+ <div>
929
+ <div className="eyebrow">首次使用</div>
930
+ <h2>{primaryNotice?.title || '5 分钟跑通 Token Studio ROI'}</h2>
931
+ <p>{primaryNotice?.detail || '按顺序准备数据、设置预算,再把 ROI 建议加入行动清单。'}</p>
932
+ </div>
933
+ <div className="first-run-actions">
934
+ {primaryNotice && (
935
+ <button className="btn btn-primary" onClick={() => runAction(primaryNotice)}>
936
+ {primaryNotice.action}
937
+ </button>
938
+ )}
939
+ <a className="btn" href="/review">打开 /review</a>
940
+ <a className="btn" href="/live">打开 /live</a>
941
+ </div>
942
+ </div>
943
+ <div className="first-run-steps">
944
+ {state.steps.map(step => (
945
+ <article key={step.id} className={`first-run-step ${step.status}`}>
946
+ <span>{step.status === 'done' ? 'Done' : 'Todo'}</span>
947
+ <strong>{step.title}</strong>
948
+ <p>{step.detail}</p>
949
+ </article>
950
+ ))}
951
+ </div>
952
+ {state.notices.length > 1 && (
953
+ <div className="first-run-notices">
954
+ {state.notices.slice(1).map(notice => (
955
+ <button key={notice.id} type="button" onClick={() => runAction(notice)}>
956
+ <strong>{notice.title}</strong>
957
+ <span>{notice.action}</span>
958
+ </button>
959
+ ))}
960
+ </div>
961
+ )}
962
+ </section>
963
+ );
964
+ }
965
+
966
+ function SourceHealthPanel({ rows = [], onOpenImportBudget }) {
967
+ if (!rows.length) return null;
968
+ const groups = {
969
+ stable: rows.filter(row => row.supportStatus === 'stable').length,
970
+ experimental: rows.filter(row => row.supportStatus === 'experimental').length,
971
+ importOnly: rows.filter(row => row.supportStatus === 'import-only').length,
972
+ detectedOnly: rows.filter(row => row.supportStatus === 'detected-only').length
973
+ };
974
+ const activeRows = rows.filter(row => row.detected || row.sessions || row.tokenEvents || row.dailyRows || row.supportStatus === 'import-only');
975
+ const visibleRows = [
976
+ ...activeRows,
977
+ ...rows.filter(row => !activeRows.includes(row)).slice(0, Math.max(0, 8 - activeRows.length))
978
+ ].slice(0, 10);
979
+
980
+ return (
981
+ <section className="source-health-panel" aria-label="Source Health Center">
982
+ <div className="source-health-head">
983
+ <div>
984
+ <div className="eyebrow">Source Health Center</div>
985
+ <h2>覆盖面靠 ccusage bridge 拉齐,事实用量只认可靠 token 字段</h2>
986
+ <p>显示 native stable、experimental、detected-only 和 import-bridge 的状态;这里不读取正文,也不暴露本机完整路径。</p>
987
+ </div>
988
+ <div className="source-health-actions">
989
+ <button className="btn btn-primary" onClick={onOpenImportBudget}>生成 ccusage 命令</button>
990
+ <a className="btn" href="/live">看实时限额</a>
991
+ </div>
992
+ </div>
993
+ <div className="source-health-stats">
994
+ <SourceHealthStat label="Stable" value={groups.stable} />
995
+ <SourceHealthStat label="Experimental" value={groups.experimental} />
996
+ <SourceHealthStat label="Import bridge" value={groups.importOnly} />
997
+ <SourceHealthStat label="Detected-only" value={groups.detectedOnly} />
998
+ </div>
999
+ <div className="source-health-grid">
1000
+ {visibleRows.map(row => (
1001
+ <article key={row.id} className={`source-health-card status-${row.supportStatus} health-${row.health}`}>
1002
+ <div className="source-health-card-top">
1003
+ <strong>{row.label}</strong>
1004
+ <span>{row.coverageTier}</span>
1005
+ </div>
1006
+ <div className="source-health-card-meta">
1007
+ <span>{row.detected ? 'detected' : 'not detected'}</span>
1008
+ <span>{row.readsConversationContent ? 'reads content' : 'no transcript'}</span>
1009
+ <span>{row.tokenReliability}</span>
1010
+ </div>
1011
+ <div className="source-health-card-counts">
1012
+ <b>{U.compactCN(row.sessions || 0)}</b>
1013
+ <span>sessions</span>
1014
+ <b>{U.compactCN(row.tokenEvents || 0)}</b>
1015
+ <span>events</span>
1016
+ <b>{U.compactCN(row.totalTokens || 0)}</b>
1017
+ <span>tokens</span>
1018
+ </div>
1019
+ <code>{row.commandHint}</code>
1020
+ </article>
1021
+ ))}
1022
+ </div>
1023
+ </section>
1024
+ );
1025
+ }
1026
+
1027
+ function SourceHealthStat({ label, value }) {
1028
+ return (
1029
+ <div>
1030
+ <span>{label}</span>
1031
+ <strong>{value}</strong>
1032
+ </div>
1033
+ );
1034
+ }
1035
+
1036
+ function CollectConfirmModal({ busy, onClose, onConfirm }) {
1037
+ return (
1038
+ <>
1039
+ <div className="modal-backdrop open" onClick={onClose}/>
1040
+ <div className="annotation-modal collect-confirm-modal" role="dialog" aria-modal="true" aria-label="确认真实采集">
1041
+ <div className="annotation-modal-header">
1042
+ <div>
1043
+ <div className="eyebrow">真实采集确认</div>
1044
+ <h3>扫描本机 Claude / Codex 用量日志</h3>
1045
+ </div>
1046
+ <button className="drawer-close" onClick={onClose} disabled={busy}>
1047
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none">
1048
+ <path d="M3 3l7 7M10 3l-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
1049
+ </svg>
1050
+ </button>
1051
+ </div>
1052
+ <div className="annotation-modal-body">
1053
+ <div className="notice-list">
1054
+ <div>本次采集会扫描本机 `.claude` / `.codex` 等已启用采集器的本地日志目录。</div>
1055
+ <div>采集器只统计 token、模型、时间、项目路径等用量字段,不读取或展示对话正文。</div>
1056
+ <div>服务端会在写入前自动复制当前 SQLite 到 `data/backups/`。</div>
1057
+ </div>
1058
+ </div>
1059
+ <div className="annotation-modal-actions">
1060
+ <button className="btn" onClick={onClose} disabled={busy}>取消</button>
1061
+ <span className="form-spacer"/>
1062
+ <button className="btn btn-primary" onClick={onConfirm} disabled={busy}>
1063
+ {busy ? '采集中' : '确认采集'}
1064
+ </button>
1065
+ </div>
1066
+ </div>
1067
+ </>
1068
+ );
1069
+ }
1070
+
1071
+ function ImportBudgetModal({
1072
+ sources,
1073
+ budgetProfiles,
1074
+ onImportCcusageJson,
1075
+ onSaveBudgetProfile,
1076
+ onDeleteBudgetProfile,
1077
+ onClose
1078
+ }) {
1079
+ const [importText, setImportText] = useState('');
1080
+ const [importResult, setImportResult] = useState(null);
1081
+ const [importError, setImportError] = useState(null);
1082
+ const [importBusy, setImportBusy] = useState(false);
1083
+ const [bridgeReport, setBridgeReport] = useState('session');
1084
+ const [bridgeMode, setBridgeMode] = useState('dry-run');
1085
+ const [bridgeCopied, setBridgeCopied] = useState(false);
1086
+ const [budgetBusy, setBudgetBusy] = useState(false);
1087
+ const [budgetError, setBudgetError] = useState(null);
1088
+ const [budgetForm, setBudgetForm] = useState(() => ({
1089
+ source: sources[0] || 'Codex CLI',
1090
+ label: '',
1091
+ windowType: 'rolling',
1092
+ windowMinutes: 60,
1093
+ resetAnchor: defaultResetAnchor(),
1094
+ warningThreshold: 0.75,
1095
+ tokenBudget: '',
1096
+ costBudgetUSD: '',
1097
+ enabled: true
1098
+ }));
1099
+ const bridgeCommand = buildCcusageBridgeCommand({
1100
+ report: bridgeReport,
1101
+ apply: bridgeMode === 'apply'
1102
+ });
1103
+
1104
+ const runImport = async (apply) => {
1105
+ setImportBusy(true);
1106
+ setImportError(null);
1107
+ try {
1108
+ const result = await onImportCcusageJson({ text: importText, apply });
1109
+ setImportResult(result);
1110
+ } catch (error) {
1111
+ setImportError(error.message || 'ccusage JSON 导入失败');
1112
+ } finally {
1113
+ setImportBusy(false);
1114
+ }
1115
+ };
1116
+
1117
+ const readImportFile = async (event) => {
1118
+ const file = event.target.files?.[0];
1119
+ if (!file) return;
1120
+ setImportText(await file.text());
1121
+ setImportResult(null);
1122
+ setImportError(null);
1123
+ };
1124
+
1125
+ const saveBudget = async () => {
1126
+ setBudgetBusy(true);
1127
+ setBudgetError(null);
1128
+ try {
1129
+ await onSaveBudgetProfile({
1130
+ source: budgetForm.source.trim(),
1131
+ label: budgetForm.label.trim() || `${budgetForm.source.trim()} custom budget`,
1132
+ windowType: budgetForm.windowType,
1133
+ windowMinutes: Number(budgetForm.windowMinutes) || 60,
1134
+ resetAnchor: budgetForm.windowType === 'fixed' ? budgetForm.resetAnchor : '',
1135
+ warningThreshold: Number(budgetForm.warningThreshold) || 0.75,
1136
+ tokenBudget: budgetForm.tokenBudget === '' ? 0 : Number(budgetForm.tokenBudget),
1137
+ costBudgetUSD: budgetForm.costBudgetUSD === '' ? 0 : Number(budgetForm.costBudgetUSD),
1138
+ enabled: budgetForm.enabled
1139
+ });
1140
+ setBudgetForm(current => ({
1141
+ ...current,
1142
+ label: '',
1143
+ resetAnchor: defaultResetAnchor(),
1144
+ tokenBudget: '',
1145
+ costBudgetUSD: ''
1146
+ }));
1147
+ } catch (error) {
1148
+ setBudgetError(error.message || '保存预算失败');
1149
+ } finally {
1150
+ setBudgetBusy(false);
1151
+ }
1152
+ };
1153
+
1154
+ const deleteBudget = async (profile) => {
1155
+ setBudgetBusy(true);
1156
+ setBudgetError(null);
1157
+ try {
1158
+ await onDeleteBudgetProfile({ id: profile.id });
1159
+ } catch (error) {
1160
+ setBudgetError(error.message || '删除预算失败');
1161
+ } finally {
1162
+ setBudgetBusy(false);
1163
+ }
1164
+ };
1165
+
1166
+ const canDryRun = importText.trim().length > 0 && !importBusy;
1167
+ const canApply = canDryRun && importResult?.mode === 'dry-run' && !importResult.error;
1168
+ const copyBridgeCommand = async () => {
1169
+ try {
1170
+ await navigator.clipboard?.writeText(bridgeCommand);
1171
+ setBridgeCopied(true);
1172
+ window.setTimeout(() => setBridgeCopied(false), 1600);
1173
+ } catch {
1174
+ setBridgeCopied(false);
1175
+ }
1176
+ };
1177
+
1178
+ return (
1179
+ <>
1180
+ <div className="modal-backdrop open" onClick={onClose}/>
1181
+ <div className="annotation-modal import-budget-modal" role="dialog" aria-modal="true" aria-label="导入与预算">
1182
+ <div className="annotation-modal-header">
1183
+ <div>
1184
+ <div className="eyebrow">导入与预算</div>
1185
+ <h3>把 ccusage JSON 和自定义预算接入 ROI 复盘</h3>
1186
+ </div>
1187
+ <button className="drawer-close" onClick={onClose} disabled={importBusy || budgetBusy}>
1188
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none">
1189
+ <path d="M3 3l7 7M10 3l-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
1190
+ </svg>
1191
+ </button>
1192
+ </div>
1193
+
1194
+ <div className="annotation-modal-body import-budget-body">
1195
+ <section className="import-budget-section">
1196
+ <div className="import-budget-section-head">
1197
+ <div>
1198
+ <h4>ccusage Saved JSON Import</h4>
1199
+ <p>只接受结构化 token/model/session/time/cache 字段;发现 prompt、response、transcript、diff、content 等正文风险字段会拒绝。</p>
1200
+ </div>
1201
+ <span className="tag tag-soft">默认 dry-run</span>
1202
+ </div>
1203
+ <div className="form-grid">
1204
+ <label className="form-field form-field-wide">
1205
+ <span>粘贴 ccusage JSON</span>
1206
+ <textarea
1207
+ value={importText}
1208
+ onChange={(event) => {
1209
+ setImportText(event.target.value);
1210
+ setImportResult(null);
1211
+ setImportError(null);
1212
+ }}
1213
+ placeholder='{"daily":[{"date":"2026-06-17","model":"claude-sonnet-4-5","inputTokens":1200,"outputTokens":300}]}'
1214
+ />
1215
+ </label>
1216
+ <label className="form-field">
1217
+ <span>或选择本地 JSON 文件</span>
1218
+ <input type="file" accept="application/json,.json" onChange={readImportFile}/>
1219
+ </label>
1220
+ <div className="import-budget-actions">
1221
+ <button className="btn" onClick={() => runImport(false)} disabled={!canDryRun}>
1222
+ {importBusy ? '预检中' : 'Dry-run 预检'}
1223
+ </button>
1224
+ <button className="btn btn-primary" onClick={() => runImport(true)} disabled={!canApply}>
1225
+ {importBusy ? '写入中' : 'Apply 写入 SQLite'}
1226
+ </button>
1227
+ </div>
1228
+ </div>
1229
+
1230
+ {importError && <div className="form-error">{importError}</div>}
1231
+ {importResult && (
1232
+ <ImportPreview result={importResult}/>
1233
+ )}
1234
+ </section>
1235
+
1236
+ <section className="import-budget-section">
1237
+ <div className="import-budget-section-head">
1238
+ <div>
1239
+ <h4>ccusage CLI Bridge</h4>
1240
+ <p>这里不从浏览器运行外部扫描器,只生成可复制命令。命令会显式调用 ccusage 输出 JSON,Token Studio 只接收结构化结果。</p>
1241
+ </div>
1242
+ <span className="tag tag-soft">copy only</span>
1243
+ </div>
1244
+ <div className="form-grid form-grid-3">
1245
+ <label className="form-field">
1246
+ <span>Report</span>
1247
+ <select value={bridgeReport} onChange={(event) => setBridgeReport(event.target.value)}>
1248
+ {CCUSAGE_BRIDGE_REPORTS.map(report => <option key={report} value={report}>{report}</option>)}
1249
+ </select>
1250
+ </label>
1251
+ <label className="form-field">
1252
+ <span>模式</span>
1253
+ <select value={bridgeMode} onChange={(event) => setBridgeMode(event.target.value)}>
1254
+ <option value="dry-run">dry-run</option>
1255
+ <option value="apply">apply</option>
1256
+ </select>
1257
+ </label>
1258
+ <div className="import-budget-actions import-budget-command-actions">
1259
+ <button className="btn" type="button" onClick={copyBridgeCommand}>
1260
+ {bridgeCopied ? '已复制' : '复制命令'}
1261
+ </button>
1262
+ </div>
1263
+ <label className="form-field form-field-wide">
1264
+ <span>复制到本地终端运行</span>
1265
+ <textarea value={bridgeCommand} readOnly rows={2}/>
1266
+ </label>
1267
+ </div>
1268
+ </section>
1269
+
1270
+ <section className="import-budget-section">
1271
+ <div className="import-budget-section-head">
1272
+ <div>
1273
+ <h4>Budget Wizard</h4>
1274
+ <p>只创建你自己的 source 级限额窗口,不内置或声称知道 Claude/Codex/Cursor 的真实套餐额度。</p>
1275
+ </div>
1276
+ <a className="btn" href="/live">打开 /live</a>
1277
+ </div>
1278
+ <div className="budget-template-row">
1279
+ {BUDGET_TEMPLATES.map(template => (
1280
+ <button
1281
+ key={template.id}
1282
+ type="button"
1283
+ className="btn btn-mini"
1284
+ onClick={() => setBudgetForm(current => applyBudgetTemplate(current, template))}
1285
+ >
1286
+ {template.label}
1287
+ </button>
1288
+ ))}
1289
+ </div>
1290
+ <div className="form-grid form-grid-3">
1291
+ <label className="form-field">
1292
+ <span>Source</span>
1293
+ <input
1294
+ list="budget-source-options"
1295
+ value={budgetForm.source}
1296
+ onChange={(event) => setBudgetForm({ ...budgetForm, source: event.target.value })}
1297
+ />
1298
+ <datalist id="budget-source-options">
1299
+ {sources.map(source => <option key={source} value={source}/>)}
1300
+ </datalist>
1301
+ </label>
1302
+ <label className="form-field">
1303
+ <span>名称</span>
1304
+ <input
1305
+ value={budgetForm.label}
1306
+ placeholder="Codex 15m budget"
1307
+ onChange={(event) => setBudgetForm({ ...budgetForm, label: event.target.value })}
1308
+ />
1309
+ </label>
1310
+ <label className="form-field">
1311
+ <span>窗口类型</span>
1312
+ <select
1313
+ value={budgetForm.windowType}
1314
+ onChange={(event) => setBudgetForm({ ...budgetForm, windowType: event.target.value })}
1315
+ >
1316
+ <option value="rolling">rolling</option>
1317
+ <option value="fixed">fixed</option>
1318
+ </select>
1319
+ </label>
1320
+ <label className="form-field">
1321
+ <span>窗口分钟</span>
1322
+ <input
1323
+ type="number"
1324
+ min="1"
1325
+ value={budgetForm.windowMinutes}
1326
+ onChange={(event) => setBudgetForm({ ...budgetForm, windowMinutes: event.target.value })}
1327
+ />
1328
+ </label>
1329
+ <label className="form-field">
1330
+ <span>Reset anchor</span>
1331
+ <input
1332
+ type="datetime-local"
1333
+ value={budgetForm.resetAnchor}
1334
+ disabled={budgetForm.windowType !== 'fixed'}
1335
+ onChange={(event) => setBudgetForm({ ...budgetForm, resetAnchor: event.target.value })}
1336
+ />
1337
+ </label>
1338
+ <label className="form-field">
1339
+ <span>预警阈值</span>
1340
+ <input
1341
+ type="number"
1342
+ min="0.1"
1343
+ max="1"
1344
+ step="0.05"
1345
+ value={budgetForm.warningThreshold}
1346
+ onChange={(event) => setBudgetForm({ ...budgetForm, warningThreshold: event.target.value })}
1347
+ />
1348
+ </label>
1349
+ <label className="form-field">
1350
+ <span>Token 预算</span>
1351
+ <input
1352
+ type="number"
1353
+ min="0"
1354
+ value={budgetForm.tokenBudget}
1355
+ placeholder="500000"
1356
+ onChange={(event) => setBudgetForm({ ...budgetForm, tokenBudget: event.target.value })}
1357
+ />
1358
+ </label>
1359
+ <label className="form-field">
1360
+ <span>官方价预算 USD</span>
1361
+ <input
1362
+ type="number"
1363
+ min="0"
1364
+ step="0.01"
1365
+ value={budgetForm.costBudgetUSD}
1366
+ placeholder="25"
1367
+ onChange={(event) => setBudgetForm({ ...budgetForm, costBudgetUSD: event.target.value })}
1368
+ />
1369
+ </label>
1370
+ <label className="form-field import-budget-toggle">
1371
+ <span>启用</span>
1372
+ <input
1373
+ type="checkbox"
1374
+ checked={budgetForm.enabled}
1375
+ onChange={(event) => setBudgetForm({ ...budgetForm, enabled: event.target.checked })}
1376
+ />
1377
+ </label>
1378
+ </div>
1379
+ {budgetError && <div className="form-error">{budgetError}</div>}
1380
+ <div className="import-budget-actions">
1381
+ <button className="btn btn-primary" onClick={saveBudget} disabled={budgetBusy || !budgetForm.source.trim() || (!budgetForm.tokenBudget && !budgetForm.costBudgetUSD)}>
1382
+ {budgetBusy ? '保存中' : '保存预算'}
1383
+ </button>
1384
+ </div>
1385
+ <BudgetProfileList profiles={budgetProfiles} busy={budgetBusy} onDelete={deleteBudget}/>
1386
+ </section>
1387
+ </div>
1388
+
1389
+ <div className="annotation-modal-actions">
1390
+ <span className="muted">写入前会由服务端创建 SQLite 备份;导入完成后去 `/review` 查看 ROI 变化。</span>
1391
+ <span className="form-spacer"/>
1392
+ <button className="btn btn-primary" onClick={onClose} disabled={importBusy || budgetBusy}>完成</button>
1393
+ </div>
1394
+ </div>
1395
+ </>
1396
+ );
1397
+ }
1398
+
1399
+ function ImportPreview({ result }) {
1400
+ const backupName = result.backup?.fileName || result.backup?.path?.split(/[\\/]/).pop();
1401
+ return (
1402
+ <div className={`import-preview import-preview-${result.mode}`}>
1403
+ <div className="import-preview-grid">
1404
+ <div><span>模式</span><strong>{result.mode}</strong></div>
1405
+ <div><span>JSON shape</span><strong>{result.detectedShape}</strong></div>
1406
+ <div><span>Daily</span><strong>{result.daily}</strong></div>
1407
+ <div><span>Sessions</span><strong>{result.sessions}</strong></div>
1408
+ <div><span>Events</span><strong>{result.tokenEvents}</strong></div>
1409
+ <div><span>写入</span><strong>{result.applied ? '已写入' : '未写入'}</strong></div>
1410
+ </div>
1411
+ {backupName && <p>备份:{backupName}</p>}
1412
+ {result.warnings?.length > 0 && (
1413
+ <div className="import-warning-list">
1414
+ {result.warnings.slice(0, 4).map((warning, index) => (
1415
+ <div key={`${warning.type}:${warning.model}:${index}`}>
1416
+ <strong>{warning.type}</strong>
1417
+ <span>{warning.model || 'unknown'} · {warning.reason}</span>
1418
+ </div>
1419
+ ))}
1420
+ </div>
1421
+ )}
1422
+ </div>
1423
+ );
1424
+ }
1425
+
1426
+ function BudgetProfileList({ profiles, busy, onDelete }) {
1427
+ if (!profiles.length) {
1428
+ return <div className="empty compact-empty">还没有预算窗口。先给常用 source 建一个自定义 token 或官方价预算。</div>;
1429
+ }
1430
+ return (
1431
+ <div className="budget-profile-list">
1432
+ {profiles.map(profile => (
1433
+ <article key={profile.id} className={`budget-profile-row ${profile.enabled ? 'enabled' : 'disabled'}`}>
1434
+ <div>
1435
+ <strong>{profile.label}</strong>
1436
+ <span>
1437
+ {profile.source || 'all sources'} · {profile.windowType || 'rolling'} · {profile.windowMinutes} min
1438
+ {profile.resetAnchor ? ` · reset ${profile.resetAnchor}` : ''}
1439
+ {' '}· warn {Math.round(Number(profile.warningThreshold || 0.75) * 100)}% · {profile.enabled ? '生效中' : '已停用'}
1440
+ </span>
1441
+ </div>
1442
+ <div>
1443
+ <b>{profile.tokenBudget ? `${U.compactCN(profile.tokenBudget)} tokens` : '— tokens'}</b>
1444
+ <span>{profile.costBudgetUSD ? U.fmtUS.format(profile.costBudgetUSD) : '— USD'}</span>
1445
+ </div>
1446
+ <button className="btn btn-mini" onClick={() => onDelete(profile)} disabled={busy}>删除</button>
1447
+ </article>
1448
+ ))}
1449
+ </div>
1450
+ );
1451
+ }
1452
+
1453
+ function OfficialPricingNotice({ meta, visibleCostUSD }) {
1454
+ if (!meta) return null;
1455
+ const unpriced = (meta.unpricedModels || []).filter(item => (item.totalTokens || 0) > 0);
1456
+ const pricedPct = ((meta.pricedShare ?? 1) * 100).toFixed(1);
1457
+ const visible = Number.isFinite(visibleCostUSD) ? visibleCostUSD : 0;
1458
+
1459
+ return (
1460
+ <section className="pricing-notice" aria-label="官方价格口径">
1461
+ <div>
1462
+ <div className="eyebrow">官方价格口径</div>
1463
+ <strong>当前筛选官方价合计 {U.fmtUS.format(visible)}</strong>
1464
+ <span>
1465
+ 按官网公开的 USD / 1M token 单价换算,覆盖 {pricedPct}% token;
1466
+ 未包含订阅额度、折扣、税费、Batch/Flex/Priority、区域加价或未公开价格模型。
1467
+ </span>
1468
+ </div>
1469
+ {unpriced.length > 0 && (
1470
+ <div className="pricing-unpriced">
1471
+ <span>未定价</span>
1472
+ {unpriced.slice(0, 4).map(item => (
1473
+ <b key={item.model} title={item.reason}>{item.model} · {U.compactCN(item.totalTokens)}</b>
1474
+ ))}
1475
+ </div>
1476
+ )}
1477
+ </section>
1478
+ );
1479
+ }
1480
+
1481
+ function ModelUsageOverview({ rows, selectedModels, onToggleModel, onClearModels }) {
1482
+ const selectedCount = selectedModels?.size || 0;
1483
+ const visibleRows = rows;
1484
+ return (
1485
+ <section className="model-overview" aria-label="模型使用概览">
1486
+ <div className="model-overview-head">
1487
+ <div>
1488
+ <div className="eyebrow">模型使用看板</div>
1489
+ <h2>按模型筛选 Token 与官方价</h2>
1490
+ </div>
1491
+ <div className="model-overview-actions">
1492
+ {selectedCount > 0 && <button className="btn" onClick={onClearModels}>全部模型</button>}
1493
+ <span>{selectedCount > 0 ? `${selectedCount} 个模型已筛选` : `${rows.length} 个模型`}</span>
1494
+ </div>
1495
+ </div>
1496
+ <div className="model-card-grid">
1497
+ {visibleRows.length === 0 && <div className="empty compact-empty">当前筛选下无模型数据</div>}
1498
+ {visibleRows.map(row => {
1499
+ const active = selectedModels?.has(row.model);
1500
+ return (
1501
+ <button
1502
+ key={row.model}
1503
+ type="button"
1504
+ className={`model-card ${active ? 'active' : ''}`}
1505
+ onClick={() => onToggleModel(row.model)}
1506
+ title={row.model}>
1507
+ <div className="model-card-top">
1508
+ <strong className="mono">{row.model}</strong>
1509
+ <span>{row.pricingStatus}</span>
1510
+ </div>
1511
+ <div className="model-card-value">{U.compactCN(row.totalTokens)}</div>
1512
+ <div className="model-card-meta">
1513
+ <span>{row.sessionCount} sessions</span>
1514
+ <span>{U.fmtUS4.format(row.costUSD || 0)}</span>
1515
+ </div>
1516
+ <div className="model-card-sub">
1517
+ <span>{row.dayCount} 天</span>
1518
+ <span>{row.sources.join(' / ') || '—'}</span>
1519
+ </div>
1520
+ </button>
1521
+ );
1522
+ })}
1523
+ </div>
1524
+ </section>
1525
+ );
1526
+ }
1527
+
1528
+ function AutoAttributionPanel({ plan, busy, message, lastRunId, onApply, onUndo }) {
1529
+ if (!plan) return null;
1530
+ const reduction = Math.max(0, Math.min(1, plan.estimatedReductionShare || 0));
1531
+ return (
1532
+ <section className="auto-attribution-panel" aria-label="懒人自动归因">
1533
+ <div className="auto-attribution-main">
1534
+ <div>
1535
+ <div className="eyebrow">懒人自动归因</div>
1536
+ <h2>先自动填高置信度,人工只处理例外</h2>
1537
+ <p>
1538
+ 只使用项目路径、来源、模型、token 结构、时间、别名规则和产出链接;
1539
+ 不读取对话正文,不调用 LLM。
1540
+ </p>
1541
+ </div>
1542
+ <div className="auto-attribution-actions">
1543
+ <button className="btn btn-primary" onClick={onApply} disabled={busy || plan.highConfidenceCount === 0}>
1544
+ {busy ? '处理中' : `一键自动填 ${plan.highConfidenceCount} 条`}
1545
+ </button>
1546
+ <button className="btn" onClick={onUndo} disabled={busy || !lastRunId}>撤销上次自动归因</button>
1547
+ </div>
1548
+ </div>
1549
+ <div className="auto-attribution-stats">
1550
+ <div>
1551
+ <span>高置信可写</span>
1552
+ <strong>{plan.highConfidenceCount}</strong>
1553
+ </div>
1554
+ <div>
1555
+ <span>待确认建议</span>
1556
+ <strong>{plan.lowConfidenceCount}</strong>
1557
+ </div>
1558
+ <div>
1559
+ <span>预计减少未归因</span>
1560
+ <strong>{(reduction * 100).toFixed(0)}%</strong>
1561
+ </div>
1562
+ <div>
1563
+ <span>规则版本</span>
1564
+ <strong>{plan.version}</strong>
1565
+ </div>
1566
+ </div>
1567
+ {message && <div className={`auto-attribution-message auto-attribution-message-${message.type}`}>{message.text}</div>}
1568
+ </section>
1569
+ );
1570
+ }
1571
+
1572
+ function AttributionOverview({ rows, totalTokens, totalSessions, onQuickAttribute }) {
1573
+ const unattributed = rows.find(row => row.id === 'unattributed');
1574
+ const unattributedCount = unattributed?.sessionCount || 0;
1575
+ const allUnattributed = totalSessions > 0 && unattributedCount === totalSessions;
1576
+ return (
1577
+ <section className="attribution-overview" aria-label="归因概览">
1578
+ <div className="attribution-overview-head">
1579
+ <div>
1580
+ <div className="eyebrow">归因概览</div>
1581
+ <h2>产出状态与官方价成本</h2>
1582
+ </div>
1583
+ <div className="attribution-overview-side">
1584
+ <div className="attribution-overview-total">
1585
+ <span>会话 Token</span>
1586
+ <strong>{U.compactCN(totalTokens)}</strong>
1587
+ </div>
1588
+ {unattributedCount > 0 && (
1589
+ <button className="btn btn-primary" onClick={onQuickAttribute}>批量归因当前筛选</button>
1590
+ )}
1591
+ </div>
1592
+ </div>
1593
+ {allUnattributed && (
1594
+ <div className="attribution-callout">
1595
+ 当前筛选还没有人工任务/状态标注。先按模型、来源或项目缩小范围,再批量归因。
1596
+ </div>
1597
+ )}
1598
+ <div className="attribution-card-grid">
1599
+ {rows.map(row => {
1600
+ const pct = row.share * 100;
1601
+ return (
1602
+ <article key={row.id} className={`attribution-card attribution-card-${row.tone}`}>
1603
+ <div className="attribution-card-top">
1604
+ <span>{row.label}</span>
1605
+ <strong>{pct.toFixed(1)}%</strong>
1606
+ </div>
1607
+ <div className="attribution-card-value">{U.compactCN(row.totalTokens)}</div>
1608
+ <div className="attribution-card-meta">
1609
+ <span>{row.sessionCount} 个 session</span>
1610
+ <span>{U.fmtUS4.format(row.costUSD || 0)}</span>
1611
+ </div>
1612
+ <div className="attribution-meter">
1613
+ <span style={{ width: `${Math.min(100, pct)}%` }} />
1614
+ </div>
1615
+ </article>
1616
+ );
1617
+ })}
1618
+ </div>
1619
+ </section>
1620
+ );
1621
+ }
1622
+
1623
+ function sessionIdentity(session) {
1624
+ return {
1625
+ device: session.device,
1626
+ source: session.source,
1627
+ sessionId: session.sessionId
1628
+ };
1629
+ }
1630
+
1631
+ function RoiReview({ riskRows, projectRows, weeklyReview, totalTokens }) {
1632
+ const topProjects = projectRows.slice(0, 5);
1633
+ return (
1634
+ <section className="roi-review" aria-label="ROI 复盘">
1635
+ <div className="roi-panel risk-panel">
1636
+ <div className="panel-header compact">
1637
+ <div>
1638
+ <div className="eyebrow">风险分布</div>
1639
+ <h3 className="panel-title">需要复盘的官方价成本</h3>
1640
+ </div>
1641
+ <span className="muted">{U.compactCN(totalTokens)} tokens</span>
1642
+ </div>
1643
+ <div className="risk-list">
1644
+ {riskRows.map(row => {
1645
+ const pct = row.share * 100;
1646
+ return (
1647
+ <div key={row.id} className={`risk-row risk-${row.tone}`}>
1648
+ <div>
1649
+ <strong>{row.label}</strong>
1650
+ <span>{row.sessionCount} sessions · {U.fmtUS4.format(row.costUSD || 0)}</span>
1651
+ </div>
1652
+ <div className="risk-meter">
1653
+ <span style={{ width: `${Math.min(100, pct)}%` }} />
1654
+ </div>
1655
+ <b>{pct.toFixed(1)}%</b>
1656
+ </div>
1657
+ );
1658
+ })}
1659
+ </div>
1660
+ </div>
1661
+
1662
+ <div className="roi-panel project-roi-panel">
1663
+ <div className="panel-header compact">
1664
+ <div>
1665
+ <div className="eyebrow">项目 ROI 排行</div>
1666
+ <h3 className="panel-title">按项目查看官方价成本</h3>
1667
+ </div>
1668
+ </div>
1669
+ <div className="project-roi-list">
1670
+ {topProjects.length === 0 && <div className="empty compact-empty">暂无项目会话</div>}
1671
+ {topProjects.map(row => (
1672
+ <article key={row.project} className="project-roi-row">
1673
+ <div className="project-roi-main">
1674
+ <strong className="mono">{row.project}</strong>
1675
+ <span>{row.sessionCount} sessions · {U.fmtUS4.format(row.costUSD || 0)}</span>
1676
+ </div>
1677
+ <div className="project-roi-bars">
1678
+ <span className="roi-published" style={{ width: pctWidth(row.publishedTokens, row.totalTokens) }} title="已发布"/>
1679
+ <span className="roi-completed" style={{ width: pctWidth(row.completedTokens, row.totalTokens) }} title="已完成"/>
1680
+ <span className="roi-discarded" style={{ width: pctWidth(row.discardedTokens, row.totalTokens) }} title="已废弃"/>
1681
+ <span className="roi-unattributed" style={{ width: pctWidth(row.unattributedTokens, row.totalTokens) }} title="未归因"/>
1682
+ </div>
1683
+ <div className="project-roi-meta">
1684
+ <span>产出 {(row.productiveShare * 100).toFixed(0)}%</span>
1685
+ <span>风险 {(row.riskShare * 100).toFixed(0)}%</span>
1686
+ <span>{U.compactCN(row.totalTokens)}</span>
1687
+ </div>
1688
+ </article>
1689
+ ))}
1690
+ </div>
1691
+ </div>
1692
+
1693
+ <div className="roi-panel weekly-panel">
1694
+ <div className="panel-header compact">
1695
+ <div>
1696
+ <div className="eyebrow">本周复盘</div>
1697
+ <h3 className="panel-title">{weeklyReview.startDate} 至 {weeklyReview.endDate}</h3>
1698
+ </div>
1699
+ <span className="muted">{U.fmtUS4.format(weeklyReview.totals.costUSD || 0)}</span>
1700
+ </div>
1701
+ <div className="weekly-grid">
1702
+ <div>
1703
+ <span className="weekly-label">高成本项目</span>
1704
+ <strong>{weeklyReview.highCostProjects[0]?.project || '暂无'}</strong>
1705
+ </div>
1706
+ <div>
1707
+ <span className="weekly-label">废弃成本</span>
1708
+ <strong>{U.fmtUS4.format(weeklyReview.discarded.costUSD || 0)}</strong>
1709
+ </div>
1710
+ <div>
1711
+ <span className="weekly-label">未归因队列</span>
1712
+ <strong>{weeklyReview.unattributedQueue.length}</strong>
1713
+ </div>
1714
+ <div>
1715
+ <span className="weekly-label">已发布产出</span>
1716
+ <strong>{weeklyReview.publishedOutputs.length}</strong>
1717
+ </div>
1718
+ </div>
1719
+ <div className="weekly-output-list">
1720
+ {weeklyReview.publishedOutputs.slice(0, 3).map(session => (
1721
+ <a key={session.sessionId} href={session.outputUrl} target="_blank" rel="noreferrer">
1722
+ {session.outputLabel || session.outputUrl}
1723
+ </a>
1724
+ ))}
1725
+ {weeklyReview.publishedOutputs.length === 0 && <span className="muted">暂无已发布产出链接</span>}
1726
+ </div>
1727
+ </div>
1728
+ </section>
1729
+ );
1730
+ }
1731
+
1732
+ function pctWidth(value, total) {
1733
+ return `${Math.max(0, Math.min(100, total ? (value / total) * 100 : 0))}%`;
1734
+ }