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,507 @@
1
+ /* =============================================================
2
+ /review — main app (real data via /api/data)
3
+ ============================================================= */
4
+
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
+ import { U } from '../shared/utils.js';
7
+ import { RU } from './utils.js';
8
+ import { HeroSection, ProjectSection, CalendarSection } from './sections-1.jsx';
9
+ import { ToolsSection, EfficiencySection, ClosureProgressSection, RoiEvidenceSection, SavingsSimulatorSection, RoiAdvisorSection, AdvisorActionSummarySection, ModelStrategySection, InsightsSection } from './sections-2.jsx';
10
+ import { buildRoiAdvisor } from './roi-advisor.js';
11
+ import { buildMarkdownReviewReport, buildReviewReportFilename } from './markdown-report.js';
12
+ import { buildModelStrategy } from './model-strategy.js';
13
+ import { buildReviewClosureProgress } from './closure-progress.js';
14
+ import { buildRoiEvidence } from './roi-evidence.js';
15
+ import { buildSavingsSimulation } from './savings-simulator.js';
16
+ import './styles.css';
17
+
18
+ export function ReviewApp() {
19
+ const [data, setData] = useState(null);
20
+ const [loading, setLoading] = useState(true);
21
+ const [error, setError] = useState(null);
22
+
23
+ useEffect(() => {
24
+ fetch('/api/data')
25
+ .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
26
+ .then(d => { setData(d); setLoading(false); })
27
+ .catch(e => { setError(e.message); setLoading(false); });
28
+ }, []);
29
+
30
+ if (loading) {
31
+ return (
32
+ <div style={{
33
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
34
+ height: '100vh', flexDirection: 'column', gap: 16
35
+ }}>
36
+ <div style={{
37
+ width: 32, height: 32, borderRadius: '50%',
38
+ border: '3px solid var(--rule)', borderTopColor: 'var(--indigo)',
39
+ animation: 'spin 0.8s linear infinite'
40
+ }}/>
41
+ <div style={{color: 'var(--ink-soft)', fontSize: 14}}>加载数据中…</div>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ if (error) {
47
+ return (
48
+ <div style={{
49
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
50
+ height: '100vh', flexDirection: 'column', gap: 12
51
+ }}>
52
+ <div style={{fontSize: 32}}>⚠️</div>
53
+ <div style={{color: 'var(--ink)', fontWeight: 600}}>数据加载失败</div>
54
+ <div style={{color: 'var(--ink-soft)', fontSize: 13}}>{error}</div>
55
+ <button onClick={() => window.location.reload()} style={{
56
+ marginTop: 8, padding: '8px 18px', borderRadius: 8,
57
+ border: '1px solid var(--rule)', background: 'var(--paper-2)',
58
+ cursor: 'pointer', fontSize: 13
59
+ }}>重新加载</button>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ return <ReviewDashboard rawData={data}/>;
65
+ }
66
+
67
+ function ReviewDashboard({ rawData }) {
68
+ const TODAY = new Date();
69
+ TODAY.setHours(0, 0, 0, 0);
70
+
71
+ const [periodId, setPeriodId] = useState('month');
72
+ const [activePageIndex, setActivePageIndex] = useState(0);
73
+ const [advisorActions, setAdvisorActions] = useState(rawData.advisorActions || []);
74
+ const pageRefs = useRef([]);
75
+ const period = useMemo(() => RU.getPeriod(periodId, TODAY, rawData.daily), [periodId, rawData.daily]);
76
+ const prevPeriod = useMemo(() => period.prev
77
+ ? { start: period.prev.start, end: period.prev.end }
78
+ : null, [period]);
79
+
80
+ const daily = useMemo(() => RU.filterByPeriod(rawData.daily, period), [rawData, period]);
81
+ const sessions = useMemo(() =>
82
+ rawData.sessions.filter(session =>
83
+ !session.lastActivity || (session.lastActivity >= period.start && session.lastActivity <= period.end)
84
+ )
85
+ , [rawData, period]);
86
+ const prevDaily = useMemo(() =>
87
+ prevPeriod ? RU.filterByPeriod(rawData.daily, prevPeriod) : []
88
+ , [rawData, prevPeriod]);
89
+
90
+ // Aggregate totals
91
+ const totals = useMemo(() => {
92
+ const total = RU.sumField(daily, 'totalTokens');
93
+ const input = RU.sumField(daily, 'inputTokens');
94
+ const output = RU.sumField(daily, 'outputTokens');
95
+ const cacheRead = RU.sumField(daily, 'cacheReadTokens');
96
+ const cacheCreation = RU.sumField(daily, 'cacheCreationTokens');
97
+ const reasoning = RU.sumField(daily, 'reasoningOutputTokens');
98
+ const cost = RU.sumField(daily, 'costUSD');
99
+ return {
100
+ total, input, output, cacheRead, cacheCreation, reasoning, cost,
101
+ cacheHitRate: total ? (cacheRead / total) * 100 : 0
102
+ };
103
+ }, [daily]);
104
+
105
+ const prevTotals = useMemo(() => prevDaily.length ? ({
106
+ total: RU.sumField(prevDaily, 'totalTokens'),
107
+ cost: RU.sumField(prevDaily, 'costUSD')
108
+ }) : null, [prevDaily]);
109
+
110
+ // Hero stat strip
111
+ const heroStats = useMemo(() => {
112
+ const days = RU.dailyTotals(daily, period);
113
+ const active = days.filter(d => d.total > 0);
114
+ const peak = active.length ? [...active].sort((a, b) => b.total - a.total)[0] : null;
115
+ const tools = RU.aggregateBy(daily, 'source').sort((a, b) => b.totalTokens - a.totalTokens);
116
+ const projects = RU.aggregateBy(daily, 'projectPath').filter(p => p.key);
117
+ const topTool = tools[0];
118
+ return {
119
+ activeDays: active.length,
120
+ projectCount: projects.length,
121
+ sourceCount: tools.length,
122
+ peakDay: peak,
123
+ topTool: topTool ? {
124
+ key: topTool.key,
125
+ short: topTool.key.replace(/ CLI| Code/, ''),
126
+ totalTokens: topTool.totalTokens,
127
+ share: (topTool.totalTokens / (totals.total || 1)) * 100
128
+ } : null,
129
+ avgDailyCost: active.length ? totals.cost / active.length : 0
130
+ };
131
+ }, [daily, period, totals]);
132
+
133
+ // Insights
134
+ const insights = useMemo(() =>
135
+ RU.buildInsights(daily, period, prevDaily)
136
+ , [daily, period, prevDaily]);
137
+ const roiAdvice = useMemo(() =>
138
+ buildRoiAdvisor({ sessions, daily })
139
+ , [sessions, daily]);
140
+ const modelStrategy = useMemo(() =>
141
+ buildModelStrategy({ sessions })
142
+ , [sessions]);
143
+ const closureProgress = useMemo(() =>
144
+ buildReviewClosureProgress({ sessions, roiAdvice })
145
+ , [sessions, roiAdvice]);
146
+ const roiEvidence = useMemo(() =>
147
+ buildRoiEvidence({ sessions, workItems: rawData.workItems || [] })
148
+ , [sessions, rawData.workItems]);
149
+ const savingsSimulation = useMemo(() =>
150
+ buildSavingsSimulation({ sessions, daily, pricingMeta: rawData.meta?.officialPricing || null })
151
+ , [sessions, daily, rawData.meta]);
152
+ const markdownReport = useMemo(() =>
153
+ buildMarkdownReviewReport({ period, daily, sessions, workItems: rawData.workItems || [], roiAdvice, insights, savingsSimulation, advisorActions })
154
+ , [period, daily, sessions, rawData.workItems, roiAdvice, insights, savingsSimulation, advisorActions]);
155
+
156
+ useEffect(() => {
157
+ setAdvisorActions(rawData.advisorActions || []);
158
+ }, [rawData.advisorActions]);
159
+
160
+ const actionsByRule = useMemo(() => {
161
+ const map = new Map();
162
+ for (const action of advisorActions) {
163
+ if (action.periodStart === period.start && action.periodEnd === period.end && action.sourceRule) {
164
+ map.set(action.sourceRule, action);
165
+ }
166
+ }
167
+ return map;
168
+ }, [advisorActions, period]);
169
+ const periodAdvisorActions = useMemo(() =>
170
+ advisorActions.filter(action => action.periodStart === period.start && action.periodEnd === period.end)
171
+ , [advisorActions, period]);
172
+
173
+ const persistAdvisorAction = useCallback(async (payload) => {
174
+ const response = await fetch('/api/advisor-actions', {
175
+ method: 'POST',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: JSON.stringify({
178
+ periodStart: period.start,
179
+ periodEnd: period.end,
180
+ ...payload
181
+ })
182
+ });
183
+ if (!response.ok) {
184
+ const error = await response.json().catch(() => ({}));
185
+ throw new Error(error.error || `HTTP ${response.status}`);
186
+ }
187
+ const data = await response.json();
188
+ setAdvisorActions(current => {
189
+ const next = current.filter(item => item.id !== data.action.id);
190
+ next.unshift(data.action);
191
+ return next;
192
+ });
193
+ return data.action;
194
+ }, [period]);
195
+
196
+ const addAdvisorAction = useCallback((payload) =>
197
+ persistAdvisorAction({ ...payload, status: 'open' })
198
+ , [persistAdvisorAction]);
199
+
200
+ const setAdvisorActionStatus = useCallback((action, status) =>
201
+ persistAdvisorAction({ ...action, status })
202
+ , [persistAdvisorAction]);
203
+
204
+ // Period nav
205
+ const ORDER = ['week', 'month', 'prev', '90d', 'all'];
206
+ const idx = ORDER.indexOf(periodId);
207
+ const prevId = idx > 0 ? ORDER[idx - 1] : null;
208
+ const nextId = idx < ORDER.length - 1 ? ORDER[idx + 1] : null;
209
+
210
+ const exportCSV = () => {
211
+ U.downloadCSV(`token-review-${period.start}-${period.end}.csv`, daily, [
212
+ { title: 'date', field: 'usageDate' },
213
+ { title: 'source', field: 'source' },
214
+ { title: 'device', field: 'device' },
215
+ { title: 'model', field: 'model' },
216
+ { title: 'project', field: 'projectPath' },
217
+ { title: 'input', field: 'inputTokens' },
218
+ { title: 'output', field: 'outputTokens' },
219
+ { title: 'cache_read', field: 'cacheReadTokens' },
220
+ { title: 'cache_creation', field: 'cacheCreationTokens' },
221
+ { title: 'reasoning', field: 'reasoningOutputTokens' },
222
+ { title: 'total', field: 'totalTokens' },
223
+ { title: 'official_price_usd', field: 'costUSD' }
224
+ ]);
225
+ };
226
+
227
+ const exportMarkdown = () => {
228
+ U.downloadText(
229
+ buildReviewReportFilename(period),
230
+ markdownReport,
231
+ 'text/markdown;charset=utf-8'
232
+ );
233
+ };
234
+
235
+ const reviewPages = useMemo(() => [
236
+ {
237
+ id: 'overview',
238
+ label: '总览',
239
+ className: 'page',
240
+ content: <HeroSection period={period} totals={totals} prevTotals={prevTotals} stats={heroStats}/>
241
+ },
242
+ {
243
+ id: 'evidence',
244
+ label: 'ROI 证据',
245
+ className: 'page',
246
+ content: <RoiEvidenceSection evidence={roiEvidence}/>
247
+ },
248
+ {
249
+ id: 'closure',
250
+ label: '闭环',
251
+ className: 'page',
252
+ content: <ClosureProgressSection progress={closureProgress}/>
253
+ },
254
+ {
255
+ id: 'projects',
256
+ label: '项目',
257
+ className: 'page',
258
+ content: <ProjectSection daily={daily} totalTokens={totals.total}/>
259
+ },
260
+ {
261
+ id: 'calendar',
262
+ label: '日历',
263
+ className: 'page-wide',
264
+ innerClassName: 'review-page-narrow',
265
+ content: <CalendarSection daily={daily} period={period}/>
266
+ },
267
+ {
268
+ id: 'tools',
269
+ label: '工具',
270
+ className: 'page-wide',
271
+ innerClassName: 'review-page-narrow',
272
+ content: <ToolsSection daily={daily} totalTokens={totals.total}/>
273
+ },
274
+ {
275
+ id: 'efficiency',
276
+ label: '效率',
277
+ className: 'page',
278
+ content: <EfficiencySection daily={daily} period={period}/>
279
+ },
280
+ {
281
+ id: 'savings',
282
+ label: '节省模拟',
283
+ className: 'page',
284
+ content: <SavingsSimulatorSection
285
+ simulation={savingsSimulation}
286
+ actionsByRule={actionsByRule}
287
+ onAddAction={addAdvisorAction}
288
+ onSetActionStatus={setAdvisorActionStatus}
289
+ />
290
+ },
291
+ {
292
+ id: 'advisor',
293
+ label: 'ROI 建议',
294
+ className: 'page',
295
+ content: <RoiAdvisorSection
296
+ suggestions={roiAdvice}
297
+ actionsByRule={actionsByRule}
298
+ onAddAction={addAdvisorAction}
299
+ onSetActionStatus={setAdvisorActionStatus}
300
+ />
301
+ },
302
+ {
303
+ id: 'actions',
304
+ label: '行动清单',
305
+ className: 'page',
306
+ content: <AdvisorActionSummarySection
307
+ actions={periodAdvisorActions}
308
+ period={period}
309
+ onSetActionStatus={setAdvisorActionStatus}
310
+ />
311
+ },
312
+ {
313
+ id: 'strategy',
314
+ label: '模型策略',
315
+ className: 'page',
316
+ content: <ModelStrategySection strategy={modelStrategy}/>
317
+ },
318
+ {
319
+ id: 'insights',
320
+ label: '洞察',
321
+ className: 'page',
322
+ content: <InsightsSection insights={insights}/>
323
+ }
324
+ ], [period, totals, prevTotals, heroStats, roiEvidence, closureProgress, daily, roiAdvice, savingsSimulation, modelStrategy, insights, actionsByRule, periodAdvisorActions, addAdvisorAction, setAdvisorActionStatus]);
325
+
326
+ const goToReviewPage = useCallback((index) => {
327
+ const nextIndex = Math.max(0, Math.min(reviewPages.length - 1, index));
328
+ const reducedMotion = typeof window !== 'undefined'
329
+ && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
330
+ setActivePageIndex(nextIndex);
331
+ pageRefs.current[nextIndex]?.scrollIntoView({
332
+ behavior: reducedMotion ? 'auto' : 'smooth',
333
+ block: 'start'
334
+ });
335
+ }, [reviewPages.length]);
336
+
337
+ useEffect(() => {
338
+ pageRefs.current = pageRefs.current.slice(0, reviewPages.length);
339
+ }, [reviewPages.length]);
340
+
341
+ useEffect(() => {
342
+ const nodes = pageRefs.current.filter(Boolean);
343
+ if (!nodes.length || typeof IntersectionObserver === 'undefined') return;
344
+
345
+ const observer = new IntersectionObserver((entries) => {
346
+ const visible = entries
347
+ .filter(entry => entry.isIntersecting)
348
+ .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
349
+
350
+ if (!visible) return;
351
+ const nextIndex = Number(visible.target.dataset.reviewPageIndex);
352
+ if (Number.isInteger(nextIndex)) setActivePageIndex(nextIndex);
353
+ }, {
354
+ root: null,
355
+ rootMargin: '-32% 0px -48% 0px',
356
+ threshold: [0.2, 0.4, 0.6]
357
+ });
358
+
359
+ nodes.forEach(node => observer.observe(node));
360
+ return () => observer.disconnect();
361
+ }, [reviewPages.length]);
362
+
363
+ return (
364
+ <>
365
+ <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
366
+ <nav className="review-nav">
367
+ <div className="review-nav-inner">
368
+ <div className="brand-line">
369
+ <span className="brand-dot"/>
370
+ <span className="brand-name">Token Studio</span>
371
+ <div className="page-switch">
372
+ <a href="/" className="page-chip">看板</a>
373
+ <span className="page-chip active">复盘</span>
374
+ <a href="/live" className="page-chip">实时</a>
375
+ </div>
376
+ </div>
377
+ <div className="period-switch">
378
+ {ORDER.map(id => (
379
+ <button key={id}
380
+ className={`period-chip ${periodId === id ? 'active' : ''}`}
381
+ onClick={() => setPeriodId(id)}>
382
+ {RU.PERIOD_LABELS[id]}
383
+ </button>
384
+ ))}
385
+ </div>
386
+ <div className="nav-actions">
387
+ <button className="nav-btn primary" onClick={exportMarkdown}>
388
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none">
389
+ <path d="M6.5 1.5v6M4 5l2.5 2.5L9 5M2.5 10.5h8" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" strokeLinejoin="round"/>
390
+ </svg>
391
+ 导出报告
392
+ </button>
393
+ <button className="nav-btn" onClick={() => window.print()}>
394
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none">
395
+ <rect x="2.5" y="4.5" width="8" height="6" rx="1" stroke="currentColor" strokeWidth="1.3"/>
396
+ <path d="M4 4.5V2h5v2.5M4 8.5h5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
397
+ </svg>
398
+ 打印
399
+ </button>
400
+ </div>
401
+ </div>
402
+ </nav>
403
+
404
+ <ReviewPageControls
405
+ pages={reviewPages}
406
+ activeIndex={activePageIndex}
407
+ onPrevious={() => goToReviewPage(activePageIndex - 1)}
408
+ onNext={() => goToReviewPage(activePageIndex + 1)}
409
+ onJump={goToReviewPage}
410
+ />
411
+
412
+ {reviewPages.map((page, index) => {
413
+ const content = page.innerClassName
414
+ ? <div className={page.innerClassName}>{page.content}</div>
415
+ : page.content;
416
+ return (
417
+ <div
418
+ key={page.id}
419
+ ref={(node) => { pageRefs.current[index] = node; }}
420
+ data-review-page-index={index}
421
+ className={`${page.className} review-page ${activePageIndex === index ? 'active' : ''}`}
422
+ >
423
+ {content}
424
+ </div>
425
+ );
426
+ })}
427
+
428
+ <footer className="review-footer">
429
+ <div className="review-footer-inner">
430
+ <div className="period-jump">
431
+ <button disabled={!prevId} onClick={() => prevId && setPeriodId(prevId)}>
432
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
433
+ <path d="M8 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
434
+ </svg>
435
+ {prevId ? RU.PERIOD_LABELS[prevId] : '更早'}
436
+ </button>
437
+ <div className="period-current">{period.pretty}</div>
438
+ <button disabled={!nextId} onClick={() => nextId && setPeriodId(nextId)}>
439
+ {nextId ? RU.PERIOD_LABELS[nextId] : '更晚'}
440
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
441
+ <path d="M4 2l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
442
+ </svg>
443
+ </button>
444
+ </div>
445
+ <button className="export-btn" onClick={exportCSV}>
446
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
447
+ <path d="M7 1v8M4 6l3 3 3-3M2 12h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
448
+ </svg>
449
+ 导出 CSV
450
+ </button>
451
+ <button className="export-btn" onClick={exportMarkdown}>
452
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
453
+ <path d="M7 1v8M4 6l3 3 3-3M2 12h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
454
+ </svg>
455
+ 导出复盘报告
456
+ </button>
457
+ </div>
458
+ </footer>
459
+ </>
460
+ );
461
+ }
462
+
463
+ function ReviewPageControls({ pages, activeIndex, onPrevious, onNext, onJump }) {
464
+ const current = pages[activeIndex] || pages[0];
465
+ return (
466
+ <div className="review-page-controls" aria-label="复盘页面导航">
467
+ <button
468
+ type="button"
469
+ className="review-page-btn"
470
+ disabled={activeIndex <= 0}
471
+ onClick={onPrevious}
472
+ >
473
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
474
+ <path d="M8.5 2.5l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
475
+ </svg>
476
+ 上一页
477
+ </button>
478
+ <div className="review-page-current">
479
+ <strong>{activeIndex + 1}/{pages.length}</strong>
480
+ <span>{current?.label || '复盘'}</span>
481
+ </div>
482
+ <div className="review-page-dots" aria-label="复盘章节">
483
+ {pages.map((page, index) => (
484
+ <button
485
+ key={page.id}
486
+ type="button"
487
+ className={`review-page-dot ${index === activeIndex ? 'active' : ''}`}
488
+ aria-label={`跳到${page.label}`}
489
+ aria-current={index === activeIndex ? 'step' : undefined}
490
+ onClick={() => onJump(index)}
491
+ />
492
+ ))}
493
+ </div>
494
+ <button
495
+ type="button"
496
+ className="review-page-btn primary"
497
+ disabled={activeIndex >= pages.length - 1}
498
+ onClick={onNext}
499
+ >
500
+ 下一页
501
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
502
+ <path d="M4.5 2.5l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
503
+ </svg>
504
+ </button>
505
+ </div>
506
+ );
507
+ }
@@ -0,0 +1,165 @@
1
+ import {
2
+ aggregateSessions,
3
+ missingReviewAttributionFields,
4
+ sessionProjectLabel
5
+ } from '../dashboard/attribution.js';
6
+
7
+ const PRODUCTIVE_STATUSES = new Set(['已完成', '已发布']);
8
+ const LABELING_CATEGORY = '补标注';
9
+
10
+ export function buildReviewClosureProgress({
11
+ sessions = [],
12
+ roiAdvice = [],
13
+ targetAttributedSessions = 10,
14
+ targetOutputLinks = 3,
15
+ targetNonLabelAdvice = 1,
16
+ topGapLimit = 5
17
+ } = {}) {
18
+ const attributedSessions = sessions.filter(isClosureAttributedSession)
19
+ .sort(compareCostThenTokens);
20
+ const manualSessions = attributedSessions.filter(isManualConfirmedSession);
21
+ const autoHighSessions = attributedSessions.filter(isAutoHighConfidenceSession);
22
+ const usableAttributionCount = manualSessions.length + autoHighSessions.length;
23
+ const autoTarget = Math.ceil(sessions.length * 0.8);
24
+ const outputSessions = sessions.filter(hasClosureOutputLink)
25
+ .sort(compareCostThenTokens);
26
+ const nonLabelAdvice = roiAdvice.filter(item => (item.category || '') !== LABELING_CATEGORY);
27
+ const topGaps = buildClosureGapRows(sessions).slice(0, Math.max(1, topGapLimit));
28
+
29
+ const checks = [
30
+ {
31
+ id: 'real-attribution',
32
+ label: '人工确认归因',
33
+ current: manualSessions.length,
34
+ target: Math.max(1, targetAttributedSessions),
35
+ unit: 'sessions',
36
+ complete: manualSessions.length >= Math.max(1, targetAttributedSessions),
37
+ detail: '需要项目别名、任务类型、产出状态、工作目的、工作阶段和产出价值全部补齐。',
38
+ action: topGaps.length
39
+ ? formatGapAction(topGaps[0])
40
+ : '继续抽查高成本 session 的项目和价值是否真实。'
41
+ },
42
+ {
43
+ id: 'lazy-auto-attribution',
44
+ label: '懒人模式可用覆盖',
45
+ current: usableAttributionCount,
46
+ target: Math.max(1, autoTarget),
47
+ unit: 'sessions',
48
+ complete: manualSessions.length >= Math.max(1, targetAttributedSessions)
49
+ || usableAttributionCount >= Math.max(1, autoTarget),
50
+ detail: '人工确认和自动高置信完整归因都计入可用覆盖;自动归因不是人工事实。',
51
+ action: '在看板使用“一键自动填高置信度”,再抽查最高成本的低置信待确认 session。'
52
+ },
53
+ {
54
+ id: 'output-links',
55
+ label: '产出链接',
56
+ current: outputSessions.length,
57
+ target: Math.max(1, targetOutputLinks),
58
+ unit: 'links',
59
+ complete: outputSessions.length >= Math.max(1, targetOutputLinks),
60
+ detail: '只统计已完成或已发布 session 上保存的 URL;不抓取链接内容。',
61
+ action: '补充产出链接:给已完成或已发布的高价值 session 补 PR、commit、文章、部署、文档或截图 URL。'
62
+ },
63
+ {
64
+ id: 'non-label-advice',
65
+ label: '非补标注 Advisor',
66
+ current: nonLabelAdvice.length,
67
+ target: Math.max(1, targetNonLabelAdvice),
68
+ unit: 'items',
69
+ complete: nonLabelAdvice.length >= Math.max(1, targetNonLabelAdvice),
70
+ detail: '至少出现一条模型切换、上下文压缩、止损、保留策略或未定价模型建议。',
71
+ action: '补齐真实归因后重新查看 ROI Advisor,确认建议不再只停留在“先补标注”。'
72
+ }
73
+ ];
74
+
75
+ const completedChecks = checks.filter(check => check.complete).length;
76
+ const remainingChecks = checks.length - completedChecks;
77
+ const aggregate = aggregateSessions(sessions);
78
+
79
+ return {
80
+ status: remainingChecks === 0 ? 'complete' : 'needs-work',
81
+ completedChecks,
82
+ totalChecks: checks.length,
83
+ remainingChecks,
84
+ completionShare: checks.length ? completedChecks / checks.length : 0,
85
+ checks,
86
+ annotatedSessions: attributedSessions.slice(0, 10),
87
+ manualSessions: manualSessions.slice(0, 10),
88
+ autoHighSessions: autoHighSessions.slice(0, 10),
89
+ outputSessions: outputSessions.slice(0, 10),
90
+ nonLabelAdvice,
91
+ topGaps,
92
+ totals: {
93
+ sessionCount: sessions.length,
94
+ totalTokens: aggregate.totalTokens,
95
+ costUSD: aggregate.costUSD
96
+ },
97
+ nextActions: checks
98
+ .filter(check => !check.complete)
99
+ .map(check => check.action)
100
+ };
101
+ }
102
+
103
+ export function isClosureAttributedSession(session = {}) {
104
+ return Boolean(String(session.projectAlias || '').trim())
105
+ && missingReviewAttributionFields(session).length === 0;
106
+ }
107
+
108
+ export function isManualConfirmedSession(session = {}) {
109
+ const source = String(session.annotationSource || 'manual');
110
+ return isClosureAttributedSession(session)
111
+ && (source === 'manual' || source === 'imported');
112
+ }
113
+
114
+ export function isAutoHighConfidenceSession(session = {}) {
115
+ return isClosureAttributedSession(session)
116
+ && session.annotationSource === 'auto'
117
+ && Number(session.annotationConfidence || 0) >= 80;
118
+ }
119
+
120
+ export function hasClosureOutputLink(session = {}) {
121
+ const url = String(session.outputUrl || '').trim();
122
+ return PRODUCTIVE_STATUSES.has(session.outputStatus)
123
+ && /^https?:\/\//i.test(url);
124
+ }
125
+
126
+ function buildClosureGapRows(sessions = []) {
127
+ return sessions
128
+ .filter(session => !isClosureAttributedSession(session))
129
+ .map(session => ({
130
+ session,
131
+ device: session.device || '',
132
+ source: session.source || '',
133
+ project: sessionProjectLabel(session),
134
+ projectPath: session.projectPath || '',
135
+ sessionId: session.sessionId || '',
136
+ model: session.model || '',
137
+ lastActivity: session.lastActivity || '',
138
+ missingFields: missingClosureFields(session),
139
+ totalTokens: session.totalTokens || 0,
140
+ costUSD: session.costUSD || 0
141
+ }))
142
+ .sort(compareCostThenTokens);
143
+ }
144
+
145
+ function missingClosureFields(session = {}) {
146
+ const fields = [];
147
+ if (!String(session.projectAlias || '').trim()) fields.push('项目别名');
148
+ fields.push(...missingReviewAttributionFields(session));
149
+ return fields;
150
+ }
151
+
152
+ function formatGapAction(row) {
153
+ const subject = row.sessionId && row.sessionId !== row.project
154
+ ? `${row.project} 的 ${row.sessionId}`
155
+ : row.project || row.sessionId || '最高成本 session';
156
+ const fields = row.missingFields.length ? `,补齐${row.missingFields.join('、')}` : '';
157
+ return `先处理 ${subject}${fields}。`;
158
+ }
159
+
160
+ function compareCostThenTokens(a, b) {
161
+ const left = a.session || a;
162
+ const right = b.session || b;
163
+ return (right.costUSD || 0) - (left.costUSD || 0)
164
+ || (right.totalTokens || 0) - (left.totalTokens || 0);
165
+ }