lumencode 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -1,809 +1,827 @@
1
- import { COLORS, SCENARIO_COLORS, TEXT, ID, STORAGE } from './config.js';
2
- import { esc, fmt, fmtShort, destroyChart, destroyAllCharts, getChart, setChart, todayISO, fmtDate } from './utils.js';
3
- import { createLatestRequestGuard, fetchTools, fetchReport, fetchConfig, saveConfig, fetchDetails, fetchSessions } from './api.js';
4
- import { renderWorkTypePie, renderModelBars, renderProjectBars, renderTimelineArea, renderCacheStack } from './charts.js';
5
- import { renderGitInsights } from './git-insights.js';
6
- import { loadWorkReport, copyWorkReport, downloadMarkdown, getWorkReportState, setWorkReportState } from './work-report.js';
7
- import { exportCSV, printReport, exportJSON, exportHTML } from './export.js';
8
-
9
- /* ── Alpine App Component ── */
10
- function appState() {
11
- return {
12
- /* state */
13
- view: 'ledger',
14
- period: 'daily',
15
- activeTool: 'all',
16
- railCollapsed: localStorage.getItem(STORAGE.SIDEBAR_COLLAPSED) === 'true',
17
- theme: localStorage.getItem(STORAGE.THEME) || 'dark',
18
- currentDate: todayISO(),
19
- today: todayISO(),
20
- loading: false,
21
- error: null,
22
- hasData: false,
23
- availableTools: [],
24
- appName: 'LumenCode',
25
- appVersion: '',
26
- lastReportData: null,
27
- cache: {},
28
- _cacheOrder: [],
29
- _cacheMaxSize: 30,
30
- reportRequestGuard: createLatestRequestGuard(),
31
-
32
- /* report view state */
33
- reportLevel: 'detailed',
34
- reportPlatform: 'default',
35
- reportProject: '',
36
- reportProjects: [],
37
- copied: false,
38
- reportHtml: '',
39
-
40
- /* constants */
41
- customStart: '',
42
- customEnd: '',
43
- periods: [
44
- { id: 'daily', cn: '日', en: 'DAY' },
45
- { id: 'weekly', cn: '周', en: 'WEEK' },
46
- { id: 'monthly', cn: '月', en: 'MONTH' },
47
- { id: 'custom', cn: '自定义', en: 'CUSTOM' },
48
- ],
49
- colors: {
50
- rust: 'var(--rust)', dest: 'var(--dest)', forest: 'var(--forest)',
51
- ochre: 'var(--ochre)', clay: 'var(--clay)',
52
- },
53
- toolColors: { claude: 'var(--claude)', codex: 'var(--codex)', opencode: 'var(--opencode)' },
54
- toolSubNames: { claude: 'ANTHROPIC', codex: 'OPENAI', opencode: 'OSS' },
55
-
56
- /* computed getters */
57
- get periodMeta() { return this.periods.find(p => p.id === this.period) || this.periods[0]; },
58
- get dateDisplay() {
59
- if (this.period === 'custom') {
60
- if (this.customStart && this.customEnd) return `${this.customStart.replace(/-/g, '.')} — ${this.customEnd.replace(/-/g, '.')}`;
61
- return '选择日期范围';
62
- }
63
- if (this.period === 'daily') return this.currentDate.replace(/-/g, '.');
64
- if (this.period === 'weekly') {
65
- const d = new Date(this.currentDate);
66
- const start = new Date(d); start.setDate(d.getDate() - d.getDay() + 1);
67
- const end = new Date(start); end.setDate(start.getDate() + 6);
68
- return `${fmtDate(start)} — ${fmtDate(end)}`;
69
- }
70
- return this.currentDate.slice(0, 7).replace('-', '.');
71
- },
72
- get generatedAt() { return fmtDate(new Date()) + ' · ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'}) + ' UTC+8'; },
73
- get traceId() { return 'CT-' + this.currentDate.replace(/-/g, '-'); },
74
-
75
- /* KPI defaults */
76
- kpiData: [
77
- { label: '活跃天数', sub: 'ACTIVE DAYS', value: '-', unit: '/ 31', delta: '', trend: 'flat' },
78
- { label: '覆盖项目', sub: 'PROJECTS', value: '-', unit: '个', delta: '', trend: 'flat' },
79
- { label: '高峰天数', sub: 'PEAK DAYS', value: '-', unit: '天', delta: '', trend: 'flat' },
80
- { label: 'Token 消耗 · 含缓存', sub: 'TOKENS · INC. CACHE', value: '-', unit: 'M', delta: '', trend: 'flat' },
81
- { label: '估算成本', sub: 'EST. COST USD', value: '-', unit: '', delta: '', trend: 'flat' },
82
- ],
83
-
84
- /* AI contribution defaults */
85
- aiLinePct: 0,
86
- aiLinePctDisplay: 0,
87
- _aiPctAnim: null,
88
- aiSummaryDesc: '',
89
- attributionPct: '0% / 100%',
90
- confirmedPct: 0,
91
- inferredPct: 0,
92
- unattribPct: 0,
93
- sourceClaudePct: 0,
94
- sourceCodexPct: 0,
95
- sourceOpencodePct: 0,
96
- sourceBreakdown: [],
97
- aiContributionMeta: '- / - LINES',
98
- gitOutputCells: [
99
- { l: '提交', en: 'COMMITS', v: '-', c: '' },
100
- { l: '变更文件', en: 'FILES', v: '-', c: '' },
101
- { l: '新增', en: '+ ADDED', v: '-', c: 'var(--forest)' },
102
- { l: '删除', en: '− REMOVED', v: '-', c: 'var(--dest)' },
103
- ],
104
- attributionCells: [
105
- { l: 'AI 改写', en: 'REWRITE', v: '-', c: '' },
106
- { l: 'AI 提交', en: 'COMMITS', v: '-', c: 'var(--forest)' },
107
- { l: '可能上限', en: 'MAX', v: '-', c: '' },
108
- { l: '高·中置信', en: 'HI · MID', v: '-', c: 'var(--ochre)' },
109
- { l: 'AI 新增', en: '+ AI', v: '-', c: 'var(--forest)' },
110
- { l: 'AI 删除', en: '− AI', v: '-', c: 'var(--dest)' },
111
- ],
112
-
113
- /* section data defaults */
114
- editTypeData: [],
115
- topFilesData: [],
116
- topFilesMeta: '+0 / −0',
117
- workTypeData: [],
118
- modelData: [],
119
- topModelName: '-',
120
- activeModels: '-',
121
- cacheHitRate: 0,
122
- cacheDelta: '',
123
- cacheData: [],
124
- cacheSavingText: '',
125
- timelineMeta: [
126
- { l: 'PEAK DAY', v: '-', s: '-' },
127
- { l: 'AVG / DAY', v: '-', s: 'sessions' },
128
- { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
129
- { l: 'IDLE DAYS', v: '-', s: 'no activity' },
130
- ],
131
- toolRankData: [],
132
- projectData: [],
133
-
134
- /* tool summary for rail */
135
- toolTokens: { all: '-' },
136
- toolSessions: { all: 0 },
137
-
138
- /* report view data */
139
- reportKpis: [
140
- { l: 'TOKENS', v: '-', s: '估算成本 -', accent: false },
141
- { l: 'COMMITS', v: '-', s: '- / - 行', accent: false },
142
- { l: 'AI CONTRIBUTION', v: '-', s: '- 行可独立运行', accent: true },
143
- { l: 'ACTIVE DAYS', v: '-', s: '连续 - 天最长', accent: false },
144
- ],
145
- reportSubTitle: '',
146
- reportSummary: '',
147
- reportHighlights: [],
148
-
149
- /* ── init ── */
150
- async init() {
151
- this.loadStateFromHash();
152
- if (this.theme === 'dark') document.documentElement.classList.add('dark');
153
- else document.documentElement.classList.remove('dark');
154
- this.$watch('view', (value) => {
155
- if (value === 'ledger' && this.lastReportData) {
156
- this.$nextTick(() => this.renderCharts(this.lastReportData));
157
- }
158
- });
159
- await this.loadTools();
160
- // 首次加载时先获取全量数据填充侧边栏,再按当前工具加载
161
- if (this.activeTool !== 'all') {
162
- try {
163
- const allData = await fetchReport({ tool: 'all', period: this.period, date: this.currentDate });
164
- if (allData && !allData.error) {
165
- this.computeToolTokens(allData.usageStats, allData.toolBreakdown);
166
- }
167
- } catch {}
168
- }
169
- await this.loadCurrentView();
170
- },
171
-
172
- /* ── theme ── */
173
- toggleTheme() {
174
- this.theme = this.theme === 'dark' ? 'light' : 'dark';
175
- localStorage.setItem(STORAGE.THEME, this.theme);
176
- if (this.theme === 'dark') document.documentElement.classList.add('dark');
177
- else document.documentElement.classList.remove('dark');
178
- /* re-render charts to pick up new colors */
179
- if (this.lastReportData && this.view === 'ledger') this.renderCharts(this.lastReportData);
180
- },
181
-
182
- /* ── tools ── */
183
- async loadTools() {
184
- try {
185
- const data = await fetchTools();
186
- this.availableTools = data.tools || data || [];
187
- if (data.appName) this.appName = data.appName;
188
- if (data.appVersion) this.appVersion = data.appVersion;
189
- } catch (e) { console.warn('loadTools failed:', e); this.availableTools = []; }
190
- },
191
-
192
- setTool(name) {
193
- this.activeTool = name;
194
- this.loadCurrentView();
195
- if (this.view === 'report') this.loadReportContent();
196
- },
197
-
198
- /* ── period / date ── */
199
- setPeriod(p) {
200
- this.period = p;
201
- if (p !== 'custom') {
202
- this.customStart = '';
203
- this.customEnd = '';
204
- this.saveStateToHash();
205
- this.loadCurrentView();
206
- if (this.view === 'report') this.loadReportContent();
207
- }
208
- },
209
-
210
- onCustomStartChange() {
211
- if (this.customStart && this.customEnd) {
212
- this.loadCurrentView();
213
- if (this.view === 'report') this.loadReportContent();
214
- }
215
- },
216
-
217
- onCustomEndChange() {
218
- if (this.customStart && this.customEnd) {
219
- this.loadCurrentView();
220
- if (this.view === 'report') this.loadReportContent();
221
- }
222
- },
223
-
224
- shiftDate(dir) {
225
- const d = new Date(this.currentDate);
226
- if (this.period === 'monthly') {
227
- d.setMonth(d.getMonth() + dir);
228
- } else {
229
- const step = this.period === 'weekly' ? 7 * dir : dir;
230
- d.setDate(d.getDate() + step);
231
- }
232
- this.currentDate = d.toISOString().slice(0, 10);
233
- this.saveStateToHash();
234
- this.loadCurrentView();
235
- if (this.view === 'report') this.loadReportContent();
236
- },
237
-
238
- onDateChange() {
239
- if (this.currentDate > this.today) this.currentDate = this.today;
240
- this.saveStateToHash();
241
- this.loadCurrentView();
242
- if (this.view === 'report') this.loadReportContent();
243
- },
244
-
245
- loadStateFromHash() {
246
- const hash = location.hash.slice(1);
247
- if (!hash) return;
248
- const [p, d] = hash.split('/');
249
- if (p && ['daily', 'weekly', 'monthly', 'custom'].includes(p)) this.period = p;
250
- if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) this.currentDate = d;
251
- },
252
-
253
- saveStateToHash() {
254
- location.hash = `${this.period}/${this.currentDate}`;
255
- },
256
-
257
- /* ── data loading ── */
258
- async loadCurrentView() {
259
- const cacheKey = `${this.activeTool}-${this.period}-${this.period === 'custom' ? this.customStart + '~' + this.customEnd : this.currentDate}`;
260
- const request = this.reportRequestGuard.next();
261
-
262
- if (this.cache[cacheKey]) {
263
- const idx = this._cacheOrder.indexOf(cacheKey);
264
- if (idx !== -1) { this._cacheOrder.splice(idx, 1); this._cacheOrder.push(cacheKey); }
265
- this.renderData(this.cache[cacheKey]);
266
- this.loading = false;
267
- return;
268
- }
269
-
270
- this.loading = true;
271
- this.error = null;
272
-
273
- try {
274
- const params = { tool: this.activeTool, period: this.period, date: this.currentDate };
275
- if (this.period === 'custom' && this.customStart && this.customEnd) {
276
- params.start = this.customStart;
277
- params.end = this.customEnd;
278
- }
279
- const data = await fetchReport(params, request.signal);
280
- if (!request.isCurrent()) return;
281
-
282
- if (!data || data.error) {
283
- this.hasData = false;
284
- if (data?.error === TEXT.NOT_CONFIGURED) {
285
- this.showWelcome();
286
- }
287
- return;
288
- }
289
-
290
- this.hideWelcome();
291
- this.cache[cacheKey] = data;
292
- this._cacheOrder.push(cacheKey);
293
- while (this._cacheOrder.length > this._cacheMaxSize) {
294
- const old = this._cacheOrder.shift();
295
- delete this.cache[old];
296
- }
297
- this.lastReportData = data;
298
- this.renderData(data);
299
- } catch (err) {
300
- if (err.name === 'AbortError') return;
301
- this.error = err.message;
302
- showToast('加载失败: ' + err.message);
303
- } finally {
304
- if (request.isCurrent()) this.loading = false;
305
- }
306
- },
307
-
308
- showWelcome() {
309
- const wp = document.getElementById(ID.WELCOME_PAGE);
310
- if (wp) wp.style.display = 'flex';
311
- },
312
-
313
- hideWelcome() {
314
- const wp = document.getElementById(ID.WELCOME_PAGE);
315
- if (wp) wp.style.display = 'none';
316
- },
317
-
318
- /* ── render data ── */
319
- renderData(data) {
320
- const { usageStats, gitStats, start, end, prevStats, trendData, costBreakdown } = data;
321
- this.hasData = usageStats.requestCount > 0;
322
- if (!this.hasData) {
323
- this.kpiData = [
324
- { label: '活跃天数', sub: 'ACTIVE DAYS', value: '-', unit: '/ ' + (this.period === 'daily' ? '1' : this.period === 'weekly' ? '7' : '31'), delta: '', trend: 'flat' },
325
- { label: '覆盖项目', sub: 'PROJECTS', value: '0', unit: '个', delta: '', trend: 'flat' },
326
- { label: '高峰天数', sub: 'PEAK DAYS', value: '-', unit: '天', delta: '', trend: 'flat' },
327
- { label: 'Token 消耗 · 含缓存', sub: 'TOKENS · INC. CACHE', value: '0.00', unit: 'M', delta: '', trend: 'flat' },
328
- { label: '估算成本', sub: 'EST. COST USD', value: '$0.00', unit: '', delta: '', trend: 'flat' },
329
- ];
330
- destroyAllCharts(['workTypeChart', 'modelChart', 'projectChart', 'toolChart', 'timelineChart', 'commitTypeChart', 'cacheChart']);
331
- return;
332
- }
333
-
334
- /* KPI strip */
335
- const days = Object.keys(usageStats.dailyStats || {}).length || 1;
336
- const totalMin = Math.round((usageStats.requestCount || 0) * 2.4);
337
- const peakDay = Object.entries(usageStats.dailyStats || {}).sort((a, b) => (b[1].requests || 0) - (a[1].requests || 0))[0];
338
- const tokensM = (usageStats.totalTokens / 1_000_000).toFixed(2);
339
- const cost = usageStats.estimatedCost || 0;
340
- const prevCost = prevStats?.estimatedCost || 0;
341
- const costDelta = prevCost > 0 ? ((cost - prevCost) / prevCost * 100).toFixed(1) : 0;
342
- const costTrend = cost > prevCost ? 'up' : cost < prevCost ? 'down' : 'flat';
343
-
344
- this.kpiData = [
345
- { label: '活跃天数', sub: 'ACTIVE DAYS', value: String(days), unit: '/ ' + (this.period === 'daily' ? '1' : this.period === 'weekly' ? '7' : '31'), delta: '', trend: 'flat' },
346
- { label: '覆盖项目', sub: 'PROJECTS', value: String(Object.keys(usageStats.projects || {}).length), unit: '个', delta: '', trend: 'flat' },
347
- { label: '高峰天数', sub: 'PEAK DAYS', value: peakDay ? peakDay[0].slice(5) : '-', unit: '天', delta: '', trend: 'flat' },
348
- { label: 'Token 消耗 · 含缓存', sub: 'TOKENS · INC. CACHE', value: tokensM, unit: 'M', delta: '', trend: 'flat' },
349
- { label: '估算成本', sub: 'EST. COST USD', value: '$' + cost.toFixed(2), unit: '', delta: (costDelta > 0 ? '+' : '') + costDelta + '%', trend: costTrend },
350
- ];
351
-
352
- /* AI contribution */
353
- this.renderAIContribution(gitStats, usageStats);
354
-
355
- /* Edit types (commit types) */
356
- const typeEntries = gitStats?.commitTypes ? Object.entries(gitStats.commitTypes).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]) : [];
357
- const maxType = Math.max(...typeEntries.map(([, v]) => v), 1);
358
- const inkSteps = ['var(--rust)', 'var(--ink-82)', 'var(--ink-62)', 'var(--ink-46)', 'var(--ink-32)', 'var(--ink-22)'];
359
- this.editTypeData = typeEntries.map(([name, value], idx) => ({
360
- name, value, pct: Math.round((value / maxType) * 100),
361
- color: inkSteps[Math.min(idx, inkSteps.length - 1)],
362
- }));
363
-
364
- /* Top files */
365
- const hotspots = gitStats?.fileHotspots || [];
366
- this.topFilesData = hotspots.slice(0, 10).map(h => ({ path: h.path, commits: h.touches, plus: h.added, minus: h.deleted }));
367
- const totalAdded = hotspots.reduce((s, h) => s + (h.added || 0), 0);
368
- const totalDeleted = hotspots.reduce((s, h) => s + (h.deleted || 0), 0);
369
- this.topFilesMeta = `+${fmt(totalAdded)} / −${fmt(totalDeleted)}`;
370
-
371
- /* Work type (scenarios) */
372
- const scenarioEntries = Object.entries(usageStats.scenarios || {}).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
373
- const totalScenario = scenarioEntries.reduce((s, [, v]) => s + v, 0) || 1;
374
- this.workTypeData = scenarioEntries.map(([name, value], i) => ({
375
- name, value: Math.round((value / totalScenario) * 100),
376
- color: SCENARIO_COLORS[name] || '#888',
377
- hidden: false,
378
- }));
379
-
380
- /* Models */
381
- const modelEntries = Object.entries(usageStats.models || {}).sort((a, b) => b[1].count - a[1].count);
382
- const maxModel = Math.max(...modelEntries.map(([, v]) => v.count), 1);
383
- const totalModelReq = modelEntries.reduce((s, [, v]) => s + v.count, 0) || 1;
384
- this.modelData = modelEntries.map(([name, d]) => ({
385
- name, pct: Math.round((d.count / totalModelReq) * 100),
386
- barPct: Math.round((d.count / maxModel) * 100),
387
- }));
388
- this.topModelName = modelEntries[0]?.[0] || '-';
389
- this.activeModels = `${modelEntries.length} / 12`;
390
-
391
- /* Cache */
392
- const cacheRead = usageStats.cacheRead || 0;
393
- const cacheCreate = usageStats.cacheCreate || 0;
394
- const inputTok = usageStats.inputTokens || 1;
395
- const cacheTotal = cacheRead + cacheCreate + inputTok;
396
- this.cacheHitRate = cacheTotal > 0 ? Math.round((cacheRead / cacheTotal) * 100) : 0;
397
- this.cacheDelta = cacheRead > 0 ? '+17pp' : '';
398
- this.cacheData = [
399
- { label: '命中', en: 'HIT', value: this.cacheHitRate, color: 'var(--forest)' },
400
- { label: '未命中', en: 'MISS', value: cacheTotal > 0 ? Math.round((inputTok / cacheTotal) * 100) : 0, color: 'var(--ochre)' },
401
- { label: '未启用', en: 'OFF', value: cacheTotal > 0 ? Math.max(0, 100 - this.cacheHitRate - Math.round((inputTok / cacheTotal) * 100)) : 0, color: 'var(--clay)' },
402
- ];
403
- const saving = costBreakdown?.cacheSaving || 0;
404
- this.cacheSavingText = saving > 0 ? `本月缓存命中节省 <span class="font-mono" style="color:var(--forest)">$${saving.toFixed(2)}</span> ≈ 总成本 ${((saving / Math.max(cost, 1)) * 100).toFixed(1)}%` : '';
405
-
406
- /* Timeline */
407
- this.renderTimeline(trendData, usageStats);
408
-
409
- /* Projects */
410
- const projEntries = Object.entries(usageStats.projects || {}).filter(([, d]) => d.requests > 0).sort((a, b) => b[1].requests - a[1].requests).slice(0, 8);
411
- this.projectData = projEntries.map(([name, d]) => ({ name: name.length > 20 ? '...' + name.slice(-17) : name, value: d.requests }));
412
-
413
- /* Tool rank */
414
- const toolEntries = Object.entries(usageStats.tools || {}).sort((a, b) => b[1] - a[1]).slice(0, 10);
415
- const maxTool = Math.max(...toolEntries.map(([, v]) => v), 1);
416
- this.toolRankData = toolEntries.map(([name, value]) => ({ name, value, pct: Math.round((value / maxTool) * 100) }));
417
-
418
- /* Tool rail tokens — only refresh sidebar when viewing all tools */
419
- if (this.activeTool === 'all') {
420
- this.computeToolTokens(usageStats, data.toolBreakdown);
421
- }
422
-
423
- /* Git insights (existing chart + table) */
424
- if (gitStats && (gitStats.commits > 0 || gitStats.filesChanged > 0)) {
425
- renderGitInsights(gitStats, this.activeTool);
426
- }
427
-
428
- /* Report view data pre-compute */
429
- this.computeReportData(data);
430
-
431
- /* Project list for report view */
432
- this.reportProjects = Object.keys(data.projectDetails || {}).sort();
433
-
434
- /* Charts (Chart.js) */
435
- this.$nextTick(() => this.renderCharts(data));
436
- },
437
-
438
- toggleWorkType(idx) {
439
- const item = this.workTypeData[idx];
440
- if (!item) return;
441
- item.hidden = !item.hidden;
442
- const chart = getChart('workTypeChart');
443
- if (chart) {
444
- chart.toggleDataVisibility(idx);
445
- chart.update();
446
- }
447
- },
448
-
449
- _animatePct(target) {
450
- if (this._aiPctAnim) cancelAnimationFrame(this._aiPctAnim);
451
- const start = this.aiLinePctDisplay || 0;
452
- const duration = 800;
453
- const t0 = performance.now();
454
- const tick = (now) => {
455
- const elapsed = now - t0;
456
- const progress = Math.min(elapsed / duration, 1);
457
- const eased = 1 - Math.pow(1 - progress, 3);
458
- this.aiLinePctDisplay = Math.round(start + (target - start) * eased);
459
- if (progress < 1) this._aiPctAnim = requestAnimationFrame(tick);
460
- };
461
- this._aiPctAnim = requestAnimationFrame(tick);
462
- },
463
-
464
- renderAIContribution(gitStats, usageStats) {
465
- const ai = gitStats?.aiContribution;
466
- if (!ai || !gitStats || gitStats.commits <= 0) {
467
- this.aiLinePct = 0;
468
- this.aiLinePctDisplay = 0;
469
- if (this._aiPctAnim) cancelAnimationFrame(this._aiPctAnim);
470
- this.aiSummaryDesc = '暂无 Git 数据';
471
- return;
472
- }
473
- const totalLines = ai.totalLinesChanged || (ai.aiFileLinesAdded + ai.aiFileLinesDeleted + (ai.humanLinesChanged || 0)) || 1;
474
- const targetPct = Math.round((ai.aiLinesChanged / totalLines) * 100) || Math.round((ai.aiLineRatio || 0) * 100);
475
- this.aiLinePct = targetPct;
476
- this._animatePct(targetPct);
477
- this.aiContributionMeta = `${fmt(ai.aiLinesChanged || 0)} / ${fmt(totalLines)} LINES`;
478
-
479
- if (gitStats.attributionSummary) {
480
- const s = gitStats.attributionSummary;
481
- const upperPct = Math.round(((s.confirmedAILines + s.probableAILines + s.possibleAILines) / (s.totalLinesChanged || 1)) * 100);
482
- this.aiSummaryDesc = `代码变更有 AI 参与(可能上限 <strong>${upperPct}%</strong>)`;
483
- this.confirmedPct = Math.round((s.confirmedAILines / (s.totalLinesChanged || 1)) * 100);
484
- this.inferredPct = Math.round((s.probableAILines / (s.totalLinesChanged || 1)) * 100);
485
- this.unattribPct = Math.max(0, 100 - this.confirmedPct - this.inferredPct);
486
- this.attributionPct = `${this.confirmedPct}% / ${upperPct}%`;
487
- } else {
488
- this.aiSummaryDesc = '代码变更有 AI 参与';
489
- this.confirmedPct = this.aiLinePct;
490
- this.inferredPct = 0;
491
- this.unattribPct = 100 - this.aiLinePct;
492
- this.attributionPct = `${this.aiLinePct}% / 100%`;
493
- }
494
-
495
- const commitPct = Math.round((ai.aiCommits / gitStats.commits) * 100);
496
- this.gitOutputCells = [
497
- { l: '提交', en: 'COMMITS', v: String(gitStats.commits), c: '' },
498
- { l: '变更文件', en: 'FILES', v: String(gitStats.filesChanged), c: '' },
499
- { l: '新增', en: '+ ADDED', v: '+' + fmt(gitStats.linesAdded), c: 'var(--forest)' },
500
- { l: '删除', en: '− REMOVED', v: '−' + fmt(gitStats.linesDeleted), c: 'var(--dest)' },
501
- ];
502
- this.attributionCells = [
503
- { l: 'AI 改写', en: 'REWRITE', v: this.aiLinePct + '%', c: '' },
504
- { l: 'AI 提交', en: 'COMMITS', v: `${ai.aiCommits}/${gitStats.commits}`, c: 'var(--forest)' },
505
- { l: '可能上限', en: 'MAX', v: (this.confirmedPct + this.inferredPct) + '%', c: '' },
506
- { l: '高·中置信', en: 'HI · MID', v: `${ai.highConfidenceCommits}/${ai.mediumConfidenceCommits}`, c: 'var(--ochre)' },
507
- { l: 'AI 新增', en: '+ AI', v: '+' + fmt(ai.aiFileLinesAdded), c: 'var(--forest)' },
508
- { l: 'AI 删除', en: '− AI', v: '−' + fmt(ai.aiFileLinesDeleted), c: 'var(--dest)' },
509
- ];
510
-
511
- /* Source breakdown from real toolBreakdown data */
512
- const toolTokMap = {};
513
- const toolColors = { claude: 'var(--claude)', codex: 'var(--codex)', opencode: 'var(--opencode)' };
514
- const toolDisplayNames = { claude: 'Claude Code', codex: 'OpenAI Codex', opencode: 'OpenCode' };
515
- if (usageStats.toolBreakdown) {
516
- for (const [k, v] of Object.entries(usageStats.toolBreakdown)) {
517
- toolTokMap[k] = (v.inputTokens || 0) + (v.outputTokens || 0);
518
- }
519
- }
520
- const entries = Object.entries(toolTokMap).filter(([, v]) => v > 0);
521
- const totalToolTok = entries.reduce((s, [, v]) => s + v, 0) || 1;
522
- const sorted = entries.sort((a, b) => b[1] - a[1]);
523
- let pctSum = 0;
524
- this.sourceBreakdown = sorted.map(([name, tok], i) => {
525
- const isLast = i === sorted.length - 1;
526
- const pct = isLast ? Math.max(0, 100 - pctSum) : Math.round((tok / totalToolTok) * 100);
527
- pctSum += pct;
528
- return { name: toolDisplayNames[name] || name, pct, tokens: fmtShort(tok), color: toolColors[name] || 'var(--foreground)' };
529
- });
530
- },
531
-
532
- renderTimeline(trendData, usageStats) {
533
- const dailyStats = trendData?.dailyStats || {};
534
- const dates = Object.keys(dailyStats).sort();
535
- if (dates.length === 0) {
536
- this.timelineMeta = [
537
- { l: 'PEAK DAY', v: '-', s: '-' },
538
- { l: 'AVG / DAY', v: '-', s: 'sessions' },
539
- { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
540
- { l: 'IDLE DAYS', v: '-', s: 'no activity' },
541
- ];
542
- return;
543
- }
544
- const sessionsArr = dates.map(d => dailyStats[d].requests || 0);
545
- const tokensArr = dates.map(d => ((dailyStats[d].inputTokens || 0) + (dailyStats[d].outputTokens || 0)) / 1_000_000);
546
- const maxSess = Math.max(...sessionsArr);
547
- const maxIdx = sessionsArr.indexOf(maxSess);
548
- const avgSess = (sessionsArr.reduce((s, v) => s + v, 0) / sessionsArr.length).toFixed(1);
549
- this.timelineMeta = [
550
- { l: 'PEAK DAY', v: dates[maxIdx]?.slice(5).replace('-', '.') || '-', s: maxSess + ' sessions' },
551
- { l: 'AVG / DAY', v: avgSess, s: 'sessions' },
552
- { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
553
- { l: 'IDLE DAYS', v: '-', s: 'no activity' },
554
- ];
555
- },
556
-
557
- computeToolTokens(usageStats, toolBreakdown) {
558
- if (!toolBreakdown || Object.keys(toolBreakdown).length === 0) {
559
- const total = usageStats.totalTokens || 0;
560
- this.toolTokens = { all: total >= 1_000_000 ? (total / 1_000_000).toFixed(2) + 'M' : fmtShort(total) };
561
- this.toolSessions = { all: usageStats.sessionCount || 0 };
562
- return;
563
- }
564
- // 从 toolBreakdown 聚合计算 all 值,确保与各工具之和一致
565
- let allTok = 0;
566
- let allSess = 0;
567
- for (const [name, data] of Object.entries(toolBreakdown)) {
568
- const tok = (data.inputTokens || 0) + (data.outputTokens || 0) + (data.cacheRead || 0) + (data.cacheCreate || 0);
569
- allTok += tok;
570
- const sess = data.sessionCount || data.sessions || 0;
571
- allSess += sess;
572
- this.toolTokens[name] = tok >= 1_000_000 ? (tok / 1_000_000).toFixed(2) + 'M' : fmtShort(tok);
573
- this.toolSessions[name] = sess;
574
- }
575
- this.toolTokens.all = allTok >= 1_000_000 ? (allTok / 1_000_000).toFixed(2) + 'M' : fmtShort(allTok);
576
- this.toolSessions.all = allSess;
577
- },
578
-
579
- computeReportData(data) {
580
- const { usageStats, gitStats, start, end, prevStats } = data;
581
- const cost = usageStats.estimatedCost || 0;
582
- const ai = gitStats?.aiContribution;
583
- const aiPct = ai ? Math.round((ai.aiLinesChanged / (ai.totalLinesChanged || 1)) * 100) : 0;
584
- const days = Object.keys(usageStats.dailyStats || {}).length || 1;
585
- this.reportKpis = [
586
- { l: 'TOKENS', v: (usageStats.totalTokens / 1_000_000).toFixed(2) + 'M', s: `估算成本 $${cost.toFixed(2)}`, accent: false },
587
- { l: 'COMMITS', v: String(gitStats?.commits || 0), s: `+${fmt(gitStats?.linesAdded || 0)} / −${fmt(gitStats?.linesDeleted || 0)} 行`, accent: false },
588
- { l: 'AI CONTRIBUTION', v: aiPct + '%', s: `${fmt(ai?.aiLinesChanged || 0)} 行可独立运行`, accent: true },
589
- { l: 'ACTIVE DAYS', v: days + ' / ' + (this.period === 'weekly' ? '7' : '31'), s: '连续 - 天最长', accent: false },
590
- ];
591
- this.reportSubTitle = `生成 ${start}${end !== start ? ' ~ ' + end : ''} · 来源 ${this.availableTools.length + 1} 个工具`;
592
- this.reportSummary = `本${this.periodMeta.cn}跨 ${this.availableTools.length + 1} 个 AI 编程工具汇总 <span class="font-mono" style="background:var(--ink-12);padding:2px 6px;border-radius:4px;">${days}</span> 个活跃工作日,消耗 <span class="font-mono" style="background:var(--ink-12);padding:2px 6px;border-radius:4px;">${(usageStats.totalTokens / 1_000_000).toFixed(2)}M</span> tokens,估算成本 <span class="font-mono" style="background:var(--ink-12);padding:2px 6px;border-radius:4px;">$${cost.toFixed(2)}</span>。AI 贡献率 <span class="font-mono" style="background:var(--ink-12);padding:2px 6px;border-radius:4px;color:var(--rust)">${aiPct}%</span>。`;
593
- this.reportHighlights = [
594
- { l: 'AI 主导编辑占比', v: aiPct + '%' },
595
- { l: '本月新增提交', v: String(gitStats?.commits || 0) },
596
- { l: '节省推理成本', v: '$' + (data.costBreakdown?.cacheSaving || 0).toFixed(2), c: 'var(--forest)' },
597
- { l: 'Cache 命中率提升', v: '+17pp', c: 'var(--forest)' },
598
- { l: '活跃模型数', v: `${Object.keys(usageStats.models || {}).length} / 12` },
599
- { l: '工作仓库数', v: String(Object.keys(usageStats.projects || {}).length) },
600
- ];
601
- },
602
-
603
- renderCharts(data) {
604
- const { usageStats, gitStats, trendData, costBreakdown } = data;
605
- if (!usageStats || usageStats.requestCount <= 0) {
606
- destroyAllCharts(['workTypeChart', 'modelChart', 'projectChart', 'toolChart', 'timelineChart', 'commitTypeChart']);
607
- return;
608
- }
609
-
610
- /* Work Type Pie */
611
- const scenarioEntries = Object.entries(usageStats.scenarios || {}).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
612
- renderWorkTypePie('workTypeChart', scenarioEntries);
613
-
614
- /* Model Bars */
615
- const modelEntries = Object.entries(usageStats.models || {}).sort((a, b) => b[1].count - a[1].count);
616
- renderModelBars('modelBarsContainer', modelEntries);
617
-
618
- /* Project Bars */
619
- const projEntries = Object.entries(usageStats.projects || {}).filter(([, d]) => d.requests > 0).sort((a, b) => b[1].requests - a[1].requests).slice(0, 8);
620
- renderProjectBars('projectChart', projEntries);
621
-
622
- /* Timeline Area */
623
- if (trendData && Object.keys(trendData.dailyStats || {}).length > 0) {
624
- renderTimelineArea('timelineChart', trendData);
625
- } else {
626
- destroyChart('timelineChart');
627
- }
628
-
629
- /* Cache is rendered via pure HTML/CSS bars in the new design */
630
- },
631
-
632
- /* ── view switching ── */
633
- openReport() {
634
- this.view = 'report';
635
- this.loadReportContent();
636
- },
637
-
638
- async loadReportContent() {
639
- try {
640
- const params = { tool: this.activeTool, period: this.period, date: this.currentDate, format: 'work', platform: this.reportPlatform, level: this.reportLevel };
641
- if (this.period === 'custom' && this.customStart && this.customEnd) {
642
- params.start = this.customStart;
643
- params.end = this.customEnd;
644
- }
645
- if (this.reportProject) {
646
- params.project = this.reportProject;
647
- }
648
- const qs = new URLSearchParams(params).toString();
649
- const res = await fetch(`/api/report?${qs}`);
650
- if (!res.ok) return;
651
- const markdown = await res.text();
652
- setWorkReportState({ markdown, platform: this.reportPlatform, level: this.reportLevel });
653
- this.reportHtml = this.renderMarkdownToReportHtml(markdown);
654
- } catch (e) { console.warn('loadReportContent failed:', e); }
655
- },
656
-
657
- setReportLevel(level) {
658
- this.reportLevel = level;
659
- this.loadReportContent();
660
- },
661
-
662
- setReportPlatform(platform) {
663
- this.reportPlatform = platform;
664
- this.loadReportContent();
665
- },
666
-
667
- setReportProject(project) {
668
- this.reportProject = project;
669
- this.loadReportContent();
670
- },
671
-
672
- async copyReport() {
673
- await copyWorkReport();
674
- this.copied = true;
675
- setTimeout(() => this.copied = false, 1400);
676
- },
677
-
678
- downloadReport() {
679
- downloadMarkdown(this.period, this.currentDate);
680
- },
681
-
682
- renderMarkdownToReportHtml(md) {
683
- const lines = md.split('\n');
684
- const out = [];
685
- let inTable = false;
686
- // Security: esc() MUST run first to neutralize HTML, then regex adds safe tags on escaped content
687
- const inline = s => esc(s).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>');
688
- for (let i = 0; i < lines.length; i++) {
689
- const line = lines[i];
690
- if (line.startsWith('|')) {
691
- if (!inTable) { inTable = true; out.push('<table class="md-table">'); }
692
- const cells = line.split('|').slice(1, -1).map(c => c.trim());
693
- if (cells.every(c => /^[-:]+$/.test(c.replace(/\|/g, '')))) continue;
694
- const tag = inTable && out[out.length - 1] === '<table class="md-table">' ? 'th' : 'td';
695
- out.push('<tr>' + cells.map(c => `<${tag}>${inline(c)}</${tag}>`).join('') + '</tr>');
696
- continue;
697
- } else if (inTable) { inTable = false; out.push('</table>'); }
698
- if (line.startsWith('# ')) { out.push(`<h1 class="md-h1">${inline(line.slice(2))}</h1>`); continue; }
699
- if (line.startsWith('## ')) { out.push(`<h2 class="md-h2">${inline(line.slice(3))}</h2>`); continue; }
700
- if (line.startsWith('### ')) { out.push(`<h3 class="md-h3">${inline(line.slice(4))}</h3>`); continue; }
701
- if (line.startsWith('- ') || line.startsWith('')) { out.push(`<li class="md-li">${inline(line.slice(2))}</li>`); continue; }
702
- if (/^[━─]+/.test(line.trim()) && line.trim().length >= 5) { out.push(`<div class="md-divider">${inline(line.trim())}</div>`); continue; }
703
- if (line.trim() === '') { out.push(''); continue; }
704
- out.push(`<p class="md-p">${inline(line)}</p>`);
705
- }
706
- if (inTable) out.push('</table>');
707
- let html = out.join('\n');
708
- html = html.replace(/(<li[^>]*>[<\s\S]*?<\/li>\n?)+/g, m => '<ul class="md-ul">\n' + m + '</ul>\n');
709
- return html;
710
- },
711
-
712
- /* ── exports ── */
713
- exportCSV() { if (this.lastReportData) exportCSV(this.lastReportData, this.period); },
714
- exportJSON() { if (this.lastReportData) exportJSON(this.lastReportData, this.period); },
715
- exportHTML() { if (this.lastReportData) exportHTML(this.lastReportData, this.period); },
716
- printReport() { if (this.lastReportData) printReport(this.lastReportData, this.period); },
717
- };
718
- }
719
-
720
- /* ── Register Alpine component ── */
721
- document.addEventListener('alpine:init', () => {
722
- Alpine.data('app', appState);
723
- });
724
-
725
- /* Dynamic load Alpine after listener is ready */
726
- const alpineScript = document.createElement('script');
727
- alpineScript.src = '/vendor/alpine.min.js';
728
- document.head.appendChild(alpineScript);
729
-
730
- /* ── Utilities ── */
731
- function showToast(msg) {
732
- const toast = document.getElementById(ID.TOAST);
733
- if (!toast) return;
734
- toast.textContent = msg;
735
- toast.style.display = 'block';
736
- toast.style.opacity = '1';
737
- setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => { toast.style.display = 'none'; }, 300); }, 3000);
738
- }
739
-
740
- /* ── Settings Modal ── */
741
- window.openSettings = async () => {
742
- const modal = document.getElementById('settingsModal');
743
- if (modal) modal.style.display = 'flex';
744
- try {
745
- const cfg = await fetchConfig();
746
- const dirEl = document.getElementById('cfgClaudeDir');
747
- const reposEl = document.getElementById('cfgRepos');
748
- const excludeEl = document.getElementById('cfgExclude');
749
- const kwEl = document.getElementById('cfgKeywords');
750
- if (dirEl) dirEl.value = cfg.claudeDir || '';
751
- if (reposEl) reposEl.value = (cfg.repos || []).join('\n');
752
- if (excludeEl) excludeEl.value = (cfg.excludeProjects || []).join('\n');
753
- if (kwEl) kwEl.value = cfg.scenarioKeywords ? JSON.stringify(cfg.scenarioKeywords, null, 2) : '{}';
754
- } catch (err) {
755
- showToast('加载配置失败: ' + err.message);
756
- }
757
- };
758
-
759
- document.getElementById('welcomeStartBtn')?.addEventListener('click', async () => {
760
- const claudeDir = document.getElementById('welcomeClaudeDir').value.trim();
761
- const reposRaw = document.getElementById('welcomeRepos').value.trim();
762
- const hint = document.getElementById('welcomeHint');
763
- if (!claudeDir) { hint.textContent = '请输入 Claude 日志目录路径'; hint.style.color = 'var(--dest)'; return; }
764
- const repos = reposRaw ? reposRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
765
- try {
766
- hint.textContent = '保存配置中...'; hint.style.color = 'var(--muted-foreground)';
767
- await saveConfig({ claudeDir, repos });
768
- hint.textContent = '配置已保存,加载数据中...';
769
- window.location.reload();
770
- } catch (err) { hint.textContent = '保存失败: ' + err.message; hint.style.color = 'var(--dest)'; }
771
- });
772
-
773
- window.saveSettings = async () => {
774
- let scenarioKeywords;
775
- try { scenarioKeywords = JSON.parse(document.getElementById('cfgKeywords').value); } catch { showToast('场景关键词 JSON 格式错误'); return; }
776
- const payload = {
777
- claudeDir: document.getElementById('cfgClaudeDir').value.trim(),
778
- repos: document.getElementById('cfgRepos').value.split('\n').map(s => s.trim()).filter(Boolean),
779
- excludeProjects: document.getElementById('cfgExclude').value.split('\n').map(s => s.trim()).filter(Boolean),
780
- scenarioKeywords,
781
- };
782
- try {
783
- await saveConfig(payload);
784
- document.getElementById('settingsModal').style.display = 'none';
785
- window.location.reload();
786
- } catch (err) { showToast('保存失败: ' + err.message); }
787
- };
788
-
789
- /* ── Drill-down global handler ── */
790
- window._drillHandler = async (type, key, label) => {
791
- const modal = document.getElementById(ID.DRILL_MODAL);
792
- const title = document.getElementById(ID.DRILL_TITLE);
793
- const body = document.getElementById(ID.DRILL_BODY);
794
- if (title) title.textContent = label + ' 匹配示例';
795
- if (body) body.innerHTML = '<div class="drill-empty">加载中...</div>';
796
- if (modal) modal.style.display = 'flex';
797
- try {
798
- const appEl = document.querySelector('[x-data]');
799
- const app = appEl?._x_dataStack?.[0];
800
- const period = app?.period || 'daily';
801
- const date = app?.currentDate || new Date().toISOString().slice(0, 10);
802
- const rows = await fetchDetails({ period, date, dimension: type, key });
803
- if (!rows.length) { if (body) body.innerHTML = '<div class="drill-empty">无匹配记录</div>'; return; }
804
- if (body) body.innerHTML = '<table class="drill-table"><tr><th>用户消息</th><th>时间</th></tr>' + rows.map(r => `<tr><td class="drill-text" title="${esc(r.text)}">${esc(r.text)}</td><td>${esc(r.timestamp?.slice(0, 16)?.replace('T', ' '))}</td></tr>`).join('') + '</table>';
805
- } catch (e) {
806
- console.warn('drillHandler failed:', e);
807
- if (body) body.innerHTML = '<div class="drill-empty">加载失败</div>';
808
- }
809
- };
1
+ import { COLORS, SCENARIO_COLORS, TEXT, ID, STORAGE } from './config.js';
2
+ import { esc, fmt, fmtShort, destroyChart, destroyAllCharts, getChart, setChart, todayISO, fmtDate } from './utils.js';
3
+ import { createLatestRequestGuard, fetchTools, fetchReport, fetchConfig, saveConfig, fetchDetails, fetchSessions } from './api.js';
4
+ import { renderWorkTypePie, renderModelBars, renderProjectBars, renderTimelineArea, renderCacheStack } from './charts.js';
5
+ import { renderGitInsights } from './git-insights.js';
6
+ import { loadWorkReport, copyWorkReport, downloadMarkdown, getWorkReportState, setWorkReportState } from './work-report.js';
7
+ import { exportCSV, printReport, exportJSON, exportHTML } from './export.js';
8
+
9
+ /* ── Alpine App Component ── */
10
+ function appState() {
11
+ return {
12
+ /* state */
13
+ view: 'ledger',
14
+ period: 'daily',
15
+ activeTool: 'all',
16
+ railCollapsed: localStorage.getItem(STORAGE.SIDEBAR_COLLAPSED) === 'true',
17
+ theme: localStorage.getItem(STORAGE.THEME) || 'dark',
18
+ currentDate: todayISO(),
19
+ today: todayISO(),
20
+ loading: false,
21
+ error: null,
22
+ hasData: false,
23
+ availableTools: [],
24
+ appName: 'LumenCode',
25
+ appVersion: '',
26
+ lastReportData: null,
27
+ cache: {},
28
+ _cacheOrder: [],
29
+ _cacheMaxSize: 30,
30
+ reportRequestGuard: createLatestRequestGuard(),
31
+
32
+ /* report view state */
33
+ reportLevel: 'detailed',
34
+ reportPlatform: 'default',
35
+ reportProject: '',
36
+ reportProjects: [],
37
+ copied: false,
38
+ reportHtml: '',
39
+
40
+ /* constants */
41
+ customStart: '',
42
+ customEnd: '',
43
+ periods: [
44
+ { id: 'daily', cn: '日', en: 'DAY' },
45
+ { id: 'weekly', cn: '周', en: 'WEEK' },
46
+ { id: 'monthly', cn: '月', en: 'MONTH' },
47
+ { id: 'custom', cn: '自定义', en: 'CUSTOM' },
48
+ ],
49
+ colors: {
50
+ rust: 'var(--rust)', dest: 'var(--dest)', forest: 'var(--forest)',
51
+ ochre: 'var(--ochre)', clay: 'var(--clay)',
52
+ },
53
+ toolColors: { claude: 'var(--claude)', codex: 'var(--codex)', opencode: 'var(--opencode)' },
54
+ toolSubNames: { claude: 'ANTHROPIC', codex: 'OPENAI', opencode: 'OSS' },
55
+
56
+ /* computed getters */
57
+ get periodMeta() { return this.periods.find(p => p.id === this.period) || this.periods[0]; },
58
+ get dateDisplay() {
59
+ if (this.period === 'custom') {
60
+ if (this.customStart && this.customEnd) return `${this.customStart.replace(/-/g, '.')} — ${this.customEnd.replace(/-/g, '.')}`;
61
+ return '选择日期范围';
62
+ }
63
+ if (this.period === 'daily') return this.currentDate.replace(/-/g, '.');
64
+ if (this.period === 'weekly') {
65
+ const d = new Date(this.currentDate);
66
+ const start = new Date(d); start.setDate(d.getDate() - d.getDay() + 1);
67
+ const end = new Date(start); end.setDate(start.getDate() + 6);
68
+ return `${fmtDate(start)} — ${fmtDate(end)}`;
69
+ }
70
+ return this.currentDate.slice(0, 7).replace('-', '.');
71
+ },
72
+ get generatedAt() { return fmtDate(new Date()) + ' · ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'}) + ' UTC+8'; },
73
+ get traceId() { return 'CT-' + this.currentDate.replace(/-/g, '-'); },
74
+
75
+ /* KPI defaults */
76
+ kpiData: [
77
+ { label: '活跃天数', sub: 'ACTIVE DAYS', value: '-', unit: '/ 31', delta: '', trend: 'flat' },
78
+ { label: '覆盖项目', sub: 'PROJECTS', value: '-', unit: '个', delta: '', trend: 'flat' },
79
+ { label: '高峰天数', sub: 'PEAK DAYS', value: '-', unit: '天', delta: '', trend: 'flat' },
80
+ { label: 'Token 消耗 · 含缓存', sub: 'TOKENS · INC. CACHE', value: '-', unit: 'M', delta: '', trend: 'flat' },
81
+ { label: '估算成本', sub: 'EST. COST USD', value: '-', unit: '', delta: '', trend: 'flat' },
82
+ ],
83
+
84
+ /* AI contribution defaults */
85
+ aiLinePct: 0,
86
+ aiLinePctDisplay: 0,
87
+ _aiPctAnim: null,
88
+ aiSummaryDesc: '',
89
+ attributionPct: '0% / 100%',
90
+ confirmedPct: 0,
91
+ inferredPct: 0,
92
+ unattribPct: 0,
93
+ sourceClaudePct: 0,
94
+ sourceCodexPct: 0,
95
+ sourceOpencodePct: 0,
96
+ sourceBreakdown: [],
97
+ aiContributionMeta: '- / - LINES',
98
+ gitOutputCells: [
99
+ { l: '提交', en: 'COMMITS', v: '-', c: '' },
100
+ { l: '变更文件', en: 'FILES', v: '-', c: '' },
101
+ { l: '新增', en: '+ ADDED', v: '-', c: 'var(--forest)' },
102
+ { l: '删除', en: '− REMOVED', v: '-', c: 'var(--dest)' },
103
+ ],
104
+ attributionCells: [
105
+ { l: 'AI 改写', en: 'REWRITE', v: '-', c: '' },
106
+ { l: 'AI 提交', en: 'COMMITS', v: '-', c: 'var(--forest)' },
107
+ { l: '可能上限', en: 'MAX', v: '-', c: '' },
108
+ { l: '高·中置信', en: 'HI · MID', v: '-', c: 'var(--ochre)' },
109
+ { l: 'AI 新增', en: '+ AI', v: '-', c: 'var(--forest)' },
110
+ { l: 'AI 删除', en: '− AI', v: '-', c: 'var(--dest)' },
111
+ ],
112
+
113
+ /* section data defaults */
114
+ editTypeData: [],
115
+ topFilesData: [],
116
+ topFilesMeta: '+0 / −0',
117
+ workTypeData: [],
118
+ modelData: [],
119
+ topModelName: '-',
120
+ activeModels: '-',
121
+ cacheHitRate: 0,
122
+ cacheDelta: '',
123
+ cacheData: [],
124
+ cacheSavingText: '',
125
+ timelineMeta: [
126
+ { l: 'PEAK DAY', v: '-', s: '-' },
127
+ { l: 'AVG / DAY', v: '-', s: 'sessions' },
128
+ { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
129
+ { l: 'IDLE DAYS', v: '-', s: 'no activity' },
130
+ ],
131
+ toolRankData: [],
132
+ projectData: [],
133
+
134
+ /* tool summary for rail */
135
+ toolTokens: { all: '-' },
136
+ toolSessions: { all: 0 },
137
+
138
+ /* report view data */
139
+ reportKpis: [
140
+ { l: 'TOKENS', v: '-', s: '估算成本 -', accent: false },
141
+ { l: 'COMMITS', v: '-', s: '- / - 行', accent: false },
142
+ { l: 'AI CONTRIBUTION', v: '-', s: '- 行可独立运行', accent: true },
143
+ { l: 'ACTIVE DAYS', v: '-', s: '连续 - 天最长', accent: false },
144
+ ],
145
+ reportSubTitle: '',
146
+ reportSummary: '',
147
+ reportHighlights: [],
148
+
149
+ /* ── init ── */
150
+ async init() {
151
+ this.loadStateFromHash();
152
+ if (this.theme === 'dark') document.documentElement.classList.add('dark');
153
+ else document.documentElement.classList.remove('dark');
154
+ this.$watch('view', (value) => {
155
+ if (value === 'ledger' && this.lastReportData) {
156
+ this.$nextTick(() => this.renderCharts(this.lastReportData));
157
+ }
158
+ });
159
+ await this.loadTools();
160
+ // 首次加载时先获取全量数据填充侧边栏,再按当前工具加载
161
+ if (this.activeTool !== 'all') {
162
+ try {
163
+ const allData = await fetchReport({ tool: 'all', period: this.period, date: this.currentDate });
164
+ if (allData && !allData.error) {
165
+ this.computeToolTokens(allData.usageStats, allData.toolBreakdown);
166
+ }
167
+ } catch {}
168
+ }
169
+ await this.loadCurrentView();
170
+ },
171
+
172
+ /* ── theme ── */
173
+ toggleTheme() {
174
+ this.theme = this.theme === 'dark' ? 'light' : 'dark';
175
+ localStorage.setItem(STORAGE.THEME, this.theme);
176
+ if (this.theme === 'dark') document.documentElement.classList.add('dark');
177
+ else document.documentElement.classList.remove('dark');
178
+ /* re-render charts to pick up new colors */
179
+ if (this.lastReportData && this.view === 'ledger') this.renderCharts(this.lastReportData);
180
+ },
181
+
182
+ /* ── tools ── */
183
+ async loadTools() {
184
+ try {
185
+ const data = await fetchTools();
186
+ this.availableTools = data.tools || data || [];
187
+ if (data.appName) this.appName = data.appName;
188
+ if (data.appVersion) this.appVersion = data.appVersion;
189
+ } catch (e) { console.warn('loadTools failed:', e); this.availableTools = []; }
190
+ },
191
+
192
+ setTool(name) {
193
+ this.activeTool = name;
194
+ this.loadCurrentView();
195
+ if (this.view === 'report') this.loadReportContent();
196
+ },
197
+
198
+ /* ── period / date ── */
199
+ setPeriod(p) {
200
+ this.period = p;
201
+ if (p !== 'custom') {
202
+ this.customStart = '';
203
+ this.customEnd = '';
204
+ this.saveStateToHash();
205
+ this.loadCurrentView();
206
+ if (this.view === 'report') this.loadReportContent();
207
+ }
208
+ },
209
+
210
+ onCustomStartChange() {
211
+ if (this.customStart && this.customEnd) {
212
+ this.loadCurrentView();
213
+ if (this.view === 'report') this.loadReportContent();
214
+ }
215
+ },
216
+
217
+ onCustomEndChange() {
218
+ if (this.customStart && this.customEnd) {
219
+ this.loadCurrentView();
220
+ if (this.view === 'report') this.loadReportContent();
221
+ }
222
+ },
223
+
224
+ shiftDate(dir) {
225
+ const d = new Date(this.currentDate);
226
+ if (this.period === 'monthly') {
227
+ d.setMonth(d.getMonth() + dir);
228
+ } else {
229
+ const step = this.period === 'weekly' ? 7 * dir : dir;
230
+ d.setDate(d.getDate() + step);
231
+ }
232
+ this.currentDate = d.toISOString().slice(0, 10);
233
+ this.saveStateToHash();
234
+ this.loadCurrentView();
235
+ if (this.view === 'report') this.loadReportContent();
236
+ },
237
+
238
+ onDateChange() {
239
+ if (this.currentDate > this.today) this.currentDate = this.today;
240
+ this.saveStateToHash();
241
+ this.loadCurrentView();
242
+ if (this.view === 'report') this.loadReportContent();
243
+ },
244
+
245
+ loadStateFromHash() {
246
+ const hash = location.hash.slice(1);
247
+ if (!hash) return;
248
+ const [p, d] = hash.split('/');
249
+ if (p && ['daily', 'weekly', 'monthly', 'custom'].includes(p)) this.period = p;
250
+ if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) this.currentDate = d;
251
+ },
252
+
253
+ saveStateToHash() {
254
+ location.hash = `${this.period}/${this.currentDate}`;
255
+ },
256
+
257
+ /* ── data loading ── */
258
+ async loadCurrentView() {
259
+ const cacheKey = `${this.activeTool}-${this.period}-${this.period === 'custom' ? this.customStart + '~' + this.customEnd : this.currentDate}`;
260
+ const request = this.reportRequestGuard.next();
261
+
262
+ if (this.cache[cacheKey]) {
263
+ const idx = this._cacheOrder.indexOf(cacheKey);
264
+ if (idx !== -1) { this._cacheOrder.splice(idx, 1); this._cacheOrder.push(cacheKey); }
265
+ this.renderData(this.cache[cacheKey]);
266
+ this.loading = false;
267
+ return;
268
+ }
269
+
270
+ this.loading = true;
271
+ this.error = null;
272
+
273
+ try {
274
+ const params = { tool: this.activeTool, period: this.period, date: this.currentDate };
275
+ if (this.period === 'custom' && this.customStart && this.customEnd) {
276
+ params.start = this.customStart;
277
+ params.end = this.customEnd;
278
+ }
279
+ const data = await fetchReport(params, request.signal);
280
+ if (!request.isCurrent()) return;
281
+
282
+ if (!data || data.error) {
283
+ this.hasData = false;
284
+ if (data?.error === TEXT.NOT_CONFIGURED) {
285
+ this.showWelcome();
286
+ }
287
+ return;
288
+ }
289
+
290
+ this.hideWelcome();
291
+ this.cache[cacheKey] = data;
292
+ this._cacheOrder.push(cacheKey);
293
+ while (this._cacheOrder.length > this._cacheMaxSize) {
294
+ const old = this._cacheOrder.shift();
295
+ delete this.cache[old];
296
+ }
297
+ this.lastReportData = data;
298
+ this.renderData(data);
299
+ } catch (err) {
300
+ if (err.name === 'AbortError') return;
301
+ this.error = err.message;
302
+ showToast('加载失败: ' + err.message);
303
+ } finally {
304
+ if (request.isCurrent()) this.loading = false;
305
+ }
306
+ },
307
+
308
+ showWelcome() {
309
+ const wp = document.getElementById(ID.WELCOME_PAGE);
310
+ if (wp) wp.style.display = 'flex';
311
+ },
312
+
313
+ hideWelcome() {
314
+ const wp = document.getElementById(ID.WELCOME_PAGE);
315
+ if (wp) wp.style.display = 'none';
316
+ },
317
+
318
+ /* ── render data ── */
319
+ renderData(data) {
320
+ const { usageStats, gitStats, start, end, prevStats, trendData, costBreakdown } = data;
321
+ this.hasData = usageStats.requestCount > 0;
322
+ if (!this.hasData) {
323
+ this.kpiData = [
324
+ { label: '活跃天数', sub: 'ACTIVE DAYS', value: '-', unit: '/ ' + (this.period === 'daily' ? '1' : this.period === 'weekly' ? '7' : '31'), delta: '', trend: 'flat' },
325
+ { label: '覆盖项目', sub: 'PROJECTS', value: '0', unit: '个', delta: '', trend: 'flat' },
326
+ { label: '高峰天数', sub: 'PEAK DAYS', value: '-', unit: '天', delta: '', trend: 'flat' },
327
+ { label: 'Token 消耗 · 含缓存', sub: 'TOKENS · INC. CACHE', value: '0.00', unit: 'M', delta: '', trend: 'flat' },
328
+ { label: '估算成本', sub: 'EST. COST USD', value: '$0.00', unit: '', delta: '', trend: 'flat' },
329
+ ];
330
+ destroyAllCharts(['workTypeChart', 'modelChart', 'projectChart', 'toolChart', 'timelineChart', 'commitTypeChart', 'cacheChart']);
331
+ return;
332
+ }
333
+
334
+ /* KPI strip */
335
+ const days = Object.keys(usageStats.dailyStats || {}).length || 1;
336
+ const totalMin = Math.round((usageStats.requestCount || 0) * 2.4);
337
+ const peakDay = Object.entries(usageStats.dailyStats || {}).sort((a, b) => (b[1].requests || 0) - (a[1].requests || 0))[0];
338
+ const tokensM = (usageStats.totalTokens / 1_000_000).toFixed(2);
339
+ const cost = usageStats.estimatedCost || 0;
340
+ const prevCost = prevStats?.estimatedCost || 0;
341
+ const costDelta = prevCost > 0 ? ((cost - prevCost) / prevCost * 100).toFixed(1) : 0;
342
+ const costTrend = cost > prevCost ? 'up' : cost < prevCost ? 'down' : 'flat';
343
+
344
+ this.kpiData = [
345
+ { label: '活跃天数', sub: 'ACTIVE DAYS', value: String(days), unit: '/ ' + (this.period === 'daily' ? '1' : this.period === 'weekly' ? '7' : '31'), delta: '', trend: 'flat' },
346
+ { label: '覆盖项目', sub: 'PROJECTS', value: String(Object.keys(usageStats.projects || {}).length), unit: '个', delta: '', trend: 'flat' },
347
+ { label: '高峰天数', sub: 'PEAK DAYS', value: peakDay ? peakDay[0].slice(5) : '-', unit: '天', delta: '', trend: 'flat' },
348
+ { label: 'Token 消耗 · 含缓存', sub: 'TOKENS · INC. CACHE', value: tokensM, unit: 'M', delta: '', trend: 'flat' },
349
+ { label: '估算成本', sub: 'EST. COST USD', value: '$' + cost.toFixed(2), unit: '', delta: (costDelta > 0 ? '+' : '') + costDelta + '%', trend: costTrend },
350
+ ];
351
+
352
+ /* AI contribution */
353
+ this.renderAIContribution(gitStats, usageStats);
354
+
355
+ /* Edit types (commit types) */
356
+ const typeEntries = gitStats?.commitTypes ? Object.entries(gitStats.commitTypes).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]) : [];
357
+ const maxType = Math.max(...typeEntries.map(([, v]) => v), 1);
358
+ const inkSteps = ['var(--rust)', 'var(--ink-82)', 'var(--ink-62)', 'var(--ink-46)', 'var(--ink-32)', 'var(--ink-22)'];
359
+ this.editTypeData = typeEntries.map(([name, value], idx) => ({
360
+ name, value, pct: Math.round((value / maxType) * 100),
361
+ color: inkSteps[Math.min(idx, inkSteps.length - 1)],
362
+ }));
363
+
364
+ /* Top files */
365
+ const hotspots = gitStats?.fileHotspots || [];
366
+ this.topFilesData = hotspots.slice(0, 10).map(h => ({ path: h.path, commits: h.touches, plus: h.added, minus: h.deleted }));
367
+ const totalAdded = hotspots.reduce((s, h) => s + (h.added || 0), 0);
368
+ const totalDeleted = hotspots.reduce((s, h) => s + (h.deleted || 0), 0);
369
+ this.topFilesMeta = `+${fmt(totalAdded)} / −${fmt(totalDeleted)}`;
370
+
371
+ /* Work type (scenarios) */
372
+ const scenarioEntries = Object.entries(usageStats.scenarios || {}).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
373
+ const totalScenario = scenarioEntries.reduce((s, [, v]) => s + v, 0) || 1;
374
+ this.workTypeData = scenarioEntries.map(([name, value], i) => ({
375
+ name, value: Math.round((value / totalScenario) * 100),
376
+ color: SCENARIO_COLORS[name] || '#888',
377
+ hidden: false,
378
+ }));
379
+
380
+ /* Models */
381
+ const modelEntries = Object.entries(usageStats.models || {}).sort((a, b) => b[1].count - a[1].count);
382
+ const maxModel = Math.max(...modelEntries.map(([, v]) => v.count), 1);
383
+ const totalModelReq = modelEntries.reduce((s, [, v]) => s + v.count, 0) || 1;
384
+ this.modelData = modelEntries.map(([name, d]) => ({
385
+ name, pct: Math.round((d.count / totalModelReq) * 100),
386
+ barPct: Math.round((d.count / maxModel) * 100),
387
+ }));
388
+ this.topModelName = modelEntries[0]?.[0] || '-';
389
+ this.activeModels = `${modelEntries.length} / 12`;
390
+
391
+ /* Cache */
392
+ const cacheRead = usageStats.cacheRead || 0;
393
+ const cacheCreate = usageStats.cacheCreate || 0;
394
+ const inputTok = usageStats.inputTokens || 1;
395
+ const cacheTotal = cacheRead + cacheCreate + inputTok;
396
+ this.cacheHitRate = cacheTotal > 0 ? Math.round((cacheRead / cacheTotal) * 100) : 0;
397
+ this.cacheDelta = cacheRead > 0 ? '+17pp' : '';
398
+ this.cacheData = [
399
+ { label: '命中', en: 'HIT', value: this.cacheHitRate, color: 'var(--forest)' },
400
+ { label: '未命中', en: 'MISS', value: cacheTotal > 0 ? Math.round((inputTok / cacheTotal) * 100) : 0, color: 'var(--ochre)' },
401
+ { label: '未启用', en: 'OFF', value: cacheTotal > 0 ? Math.max(0, 100 - this.cacheHitRate - Math.round((inputTok / cacheTotal) * 100)) : 0, color: 'var(--clay)' },
402
+ ];
403
+ const saving = costBreakdown?.cacheSaving || 0;
404
+ this.cacheSavingText = saving > 0 ? `本月缓存命中节省 <span class="font-mono" style="color:var(--forest)">$${saving.toFixed(2)}</span> ≈ 总成本 ${((saving / Math.max(cost, 1)) * 100).toFixed(1)}%` : '';
405
+
406
+ /* Timeline */
407
+ this.renderTimeline(trendData, usageStats);
408
+
409
+ /* Projects */
410
+ const projEntries = Object.entries(usageStats.projects || {}).filter(([, d]) => d.requests > 0).sort((a, b) => b[1].requests - a[1].requests).slice(0, 8);
411
+ this.projectData = projEntries.map(([name, d]) => ({ name: name.length > 20 ? '...' + name.slice(-17) : name, value: d.requests }));
412
+
413
+ /* Tool rank */
414
+ const toolEntries = Object.entries(usageStats.tools || {}).sort((a, b) => b[1] - a[1]).slice(0, 10);
415
+ const maxTool = Math.max(...toolEntries.map(([, v]) => v), 1);
416
+ this.toolRankData = toolEntries.map(([name, value]) => ({ name, value, pct: Math.round((value / maxTool) * 100) }));
417
+
418
+ /* Tool rail tokens — only refresh sidebar when viewing all tools */
419
+ if (this.activeTool === 'all') {
420
+ this.computeToolTokens(usageStats, data.toolBreakdown);
421
+ }
422
+
423
+ /* Git insights (existing chart + table) */
424
+ if (gitStats && (gitStats.commits > 0 || gitStats.filesChanged > 0)) {
425
+ renderGitInsights(gitStats, this.activeTool);
426
+ }
427
+
428
+ /* Report view data pre-compute */
429
+ this.computeReportData(data);
430
+
431
+ /* Project list for report view */
432
+ this.reportProjects = Object.keys(data.projectDetails || {}).sort();
433
+
434
+ /* Charts (Chart.js) */
435
+ this.$nextTick(() => this.renderCharts(data));
436
+ },
437
+
438
+ toggleWorkType(idx) {
439
+ const item = this.workTypeData[idx];
440
+ if (!item) return;
441
+ item.hidden = !item.hidden;
442
+ const chart = getChart('workTypeChart');
443
+ if (chart) {
444
+ chart.toggleDataVisibility(idx);
445
+ chart.update();
446
+ }
447
+ },
448
+
449
+ _animatePct(target) {
450
+ if (this._aiPctAnim) cancelAnimationFrame(this._aiPctAnim);
451
+ const start = this.aiLinePctDisplay || 0;
452
+ const duration = 800;
453
+ const t0 = performance.now();
454
+ const tick = (now) => {
455
+ const elapsed = now - t0;
456
+ const progress = Math.min(elapsed / duration, 1);
457
+ const eased = 1 - Math.pow(1 - progress, 3);
458
+ this.aiLinePctDisplay = Math.round(start + (target - start) * eased);
459
+ if (progress < 1) this._aiPctAnim = requestAnimationFrame(tick);
460
+ };
461
+ this._aiPctAnim = requestAnimationFrame(tick);
462
+ },
463
+
464
+ renderAIContribution(gitStats, usageStats) {
465
+ const ai = gitStats?.aiContribution;
466
+ if (!ai || !gitStats || gitStats.commits <= 0) {
467
+ this.aiLinePct = 0;
468
+ this.aiLinePctDisplay = 0;
469
+ if (this._aiPctAnim) cancelAnimationFrame(this._aiPctAnim);
470
+ this.aiSummaryDesc = '暂无 Git 数据';
471
+ return;
472
+ }
473
+ const totalLines = ai.totalLinesChanged || (ai.aiFileLinesAdded + ai.aiFileLinesDeleted + (ai.humanLinesChanged || 0)) || 1;
474
+ const targetPct = Math.round((ai.aiLinesChanged / totalLines) * 100) || Math.round((ai.aiLineRatio || 0) * 100);
475
+ this.aiLinePct = targetPct;
476
+ this._animatePct(targetPct);
477
+ this.aiContributionMeta = `${fmt(ai.aiLinesChanged || 0)} / ${fmt(totalLines)} LINES`;
478
+
479
+ if (gitStats.attributionSummary) {
480
+ const s = gitStats.attributionSummary;
481
+ const upperPct = Math.round(((s.confirmedAILines + s.probableAILines + s.possibleAILines) / (s.totalLinesChanged || 1)) * 100);
482
+ const weightedPct = Math.round((ai.weightedAILineRatio || 0) * 100);
483
+ let desc = '代码变更有 AI 参与';
484
+ if (ai.possibleAICommits > 0) {
485
+ desc += `,可能 AI 影响 <strong>${ai.possibleAICommits}</strong> 提交`;
486
+ }
487
+ if (weightedPct > targetPct) {
488
+ desc += `,加权影响力 <strong>${weightedPct}%</strong>`;
489
+ }
490
+ this.aiSummaryDesc = desc;
491
+ this.confirmedPct = Math.round((s.confirmedAILines / (s.totalLinesChanged || 1)) * 100);
492
+ this.inferredPct = Math.round((s.probableAILines / (s.totalLinesChanged || 1)) * 100);
493
+ this.unattribPct = Math.max(0, 100 - this.confirmedPct - this.inferredPct);
494
+ this.attributionPct = `${this.confirmedPct}% / ${upperPct}%`;
495
+ } else {
496
+ this.aiSummaryDesc = '代码变更有 AI 参与';
497
+ this.confirmedPct = this.aiLinePct;
498
+ this.inferredPct = 0;
499
+ this.unattribPct = 100 - this.aiLinePct;
500
+ this.attributionPct = `${this.aiLinePct}% / 100%`;
501
+ }
502
+
503
+ const commitPct = Math.round((ai.aiCommits / gitStats.commits) * 100);
504
+ this.gitOutputCells = [
505
+ { l: '提交', en: 'COMMITS', v: String(gitStats.commits), c: '' },
506
+ { l: '变更文件', en: 'FILES', v: String(gitStats.filesChanged), c: '' },
507
+ { l: '新增', en: '+ ADDED', v: '+' + fmt(gitStats.linesAdded), c: 'var(--forest)' },
508
+ { l: '删除', en: '− REMOVED', v: '−' + fmt(gitStats.linesDeleted), c: 'var(--dest)' },
509
+ ];
510
+ this.attributionCells = [
511
+ { l: 'AI 改写', en: 'REWRITE', v: this.aiLinePct + '%', c: '' },
512
+ { l: 'AI 提交', en: 'COMMITS', v: `${ai.aiCommits}/${gitStats.commits}`, c: 'var(--forest)' },
513
+ { l: '可能上限', en: 'MAX', v: (this.confirmedPct + this.inferredPct) + '%', c: '' },
514
+ { l: '高·中置信', en: 'HI · MID', v: `${ai.highConfidenceCommits}/${ai.mediumConfidenceCommits}`, c: 'var(--ochre)' },
515
+ { l: 'AI 新增', en: '+ AI', v: '+' + fmt(ai.aiFileLinesAdded), c: 'var(--forest)' },
516
+ { l: 'AI 删除', en: '− AI', v: '−' + fmt(ai.aiFileLinesDeleted), c: 'var(--dest)' },
517
+ ];
518
+
519
+ /* Source breakdown from real toolBreakdown data */
520
+ const toolTokMap = {};
521
+ const toolColors = { claude: 'var(--claude)', codex: 'var(--codex)', opencode: 'var(--opencode)' };
522
+ const toolDisplayNames = { claude: 'Claude Code', codex: 'OpenAI Codex', opencode: 'OpenCode' };
523
+ if (usageStats.toolBreakdown) {
524
+ for (const [k, v] of Object.entries(usageStats.toolBreakdown)) {
525
+ toolTokMap[k] = (v.inputTokens || 0) + (v.outputTokens || 0);
526
+ }
527
+ }
528
+ const entries = Object.entries(toolTokMap).filter(([, v]) => v > 0);
529
+ const totalToolTok = entries.reduce((s, [, v]) => s + v, 0) || 1;
530
+ const sorted = entries.sort((a, b) => b[1] - a[1]);
531
+ let pctSum = 0;
532
+ this.sourceBreakdown = sorted.map(([name, tok], i) => {
533
+ const isLast = i === sorted.length - 1;
534
+ const pct = isLast ? Math.max(0, 100 - pctSum) : Math.round((tok / totalToolTok) * 100);
535
+ pctSum += pct;
536
+ return { name: toolDisplayNames[name] || name, pct, tokens: fmtShort(tok), color: toolColors[name] || 'var(--foreground)' };
537
+ });
538
+ },
539
+
540
+ renderTimeline(trendData, usageStats) {
541
+ const dailyStats = trendData?.dailyStats || {};
542
+ const dates = Object.keys(dailyStats).sort();
543
+ if (dates.length === 0) {
544
+ this.timelineMeta = [
545
+ { l: 'PEAK DAY', v: '-', s: '-' },
546
+ { l: 'AVG / DAY', v: '-', s: 'sessions' },
547
+ { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
548
+ { l: 'IDLE DAYS', v: '-', s: 'no activity' },
549
+ ];
550
+ return;
551
+ }
552
+ const sessionsArr = dates.map(d => dailyStats[d].requests || 0);
553
+ const tokensArr = dates.map(d => ((dailyStats[d].inputTokens || 0) + (dailyStats[d].outputTokens || 0)) / 1_000_000);
554
+ const maxSess = Math.max(...sessionsArr);
555
+ const maxIdx = sessionsArr.indexOf(maxSess);
556
+ const avgSess = (sessionsArr.reduce((s, v) => s + v, 0) / sessionsArr.length).toFixed(1);
557
+ this.timelineMeta = [
558
+ { l: 'PEAK DAY', v: dates[maxIdx]?.slice(5).replace('-', '.') || '-', s: maxSess + ' sessions' },
559
+ { l: 'AVG / DAY', v: avgSess, s: 'sessions' },
560
+ { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
561
+ { l: 'IDLE DAYS', v: '-', s: 'no activity' },
562
+ ];
563
+ },
564
+
565
+ computeToolTokens(usageStats, toolBreakdown) {
566
+ if (!toolBreakdown || Object.keys(toolBreakdown).length === 0) {
567
+ const total = usageStats.totalTokens || 0;
568
+ this.toolTokens = { all: total >= 1_000_000 ? (total / 1_000_000).toFixed(2) + 'M' : fmtShort(total) };
569
+ this.toolSessions = { all: usageStats.sessionCount || 0 };
570
+ return;
571
+ }
572
+ // toolBreakdown 聚合计算 all 值,确保与各工具之和一致
573
+ let allTok = 0;
574
+ let allSess = 0;
575
+ for (const [name, data] of Object.entries(toolBreakdown)) {
576
+ const tok = (data.inputTokens || 0) + (data.outputTokens || 0) + (data.cacheRead || 0) + (data.cacheCreate || 0);
577
+ allTok += tok;
578
+ const sess = data.sessionCount || data.sessions || 0;
579
+ allSess += sess;
580
+ this.toolTokens[name] = tok >= 1_000_000 ? (tok / 1_000_000).toFixed(2) + 'M' : fmtShort(tok);
581
+ this.toolSessions[name] = sess;
582
+ }
583
+ this.toolTokens.all = allTok >= 1_000_000 ? (allTok / 1_000_000).toFixed(2) + 'M' : fmtShort(allTok);
584
+ this.toolSessions.all = allSess;
585
+ },
586
+
587
+ computeReportData(data) {
588
+ const { usageStats, gitStats, start, end, prevStats } = data;
589
+ const cost = usageStats.estimatedCost || 0;
590
+ const ai = gitStats?.aiContribution;
591
+ const aiPct = ai ? Math.round((ai.aiLinesChanged / (ai.totalLinesChanged || 1)) * 100) : 0;
592
+ const weightedPct = ai ? Math.round((ai.weightedAILineRatio || 0) * 100) : 0;
593
+ const days = Object.keys(usageStats.dailyStats || {}).length || 1;
594
+ let aiSubText = `${fmt(ai?.aiLinesChanged || 0)} 行严格可认定`;
595
+ if (ai?.possibleAICommits > 0) {
596
+ aiSubText += `,${ai.possibleAICommits} 提交可能 AI 参与`;
597
+ }
598
+ this.reportKpis = [
599
+ { l: 'TOKENS', v: (usageStats.totalTokens / 1_000_000).toFixed(2) + 'M', s: `估算成本 $${cost.toFixed(2)}`, accent: false },
600
+ { l: 'COMMITS', v: String(gitStats?.commits || 0), s: `+${fmt(gitStats?.linesAdded || 0)} / −${fmt(gitStats?.linesDeleted || 0)} 行`, accent: false },
601
+ { l: 'AI CONTRIBUTION', v: aiPct + '%', s: aiSubText, accent: true },
602
+ { l: 'ACTIVE DAYS', v: days + ' / ' + (this.period === 'weekly' ? '7' : '31'), s: '连续 - 天最长', accent: false },
603
+ ];
604
+ this.reportSubTitle = `生成 ${start}${end !== start ? ' ~ ' + end : ''} · 来源 ${this.availableTools.length + 1} 个工具`;
605
+ let summaryText = `本${this.periodMeta.cn}跨 ${this.availableTools.length + 1} 个 AI 编程工具汇总 <span class="font-mono" style="background:var(--ink-12);padding:2px 6px;border-radius:4px;">${days}</span> 个活跃工作日,消耗 <span class="font-mono" style="background:var(--ink-12);padding:2px 6px;border-radius:4px;">${(usageStats.totalTokens / 1_000_000).toFixed(2)}M</span> tokens,估算成本 <span class="font-mono" style="background:var(--ink-12);padding:2px 6px;border-radius:4px;">$${cost.toFixed(2)}</span>。AI 贡献率 <span class="font-mono" style="background:var(--ink-12);padding:2px 6px;border-radius:4px;color:var(--rust)">${aiPct}%</span>`;
606
+ if (weightedPct > aiPct) {
607
+ summaryText += `,加权 AI 影响力 ${weightedPct}%`;
608
+ }
609
+ summaryText += '。';
610
+ this.reportSummary = summaryText;
611
+ this.reportHighlights = [
612
+ { l: 'AI 主导编辑占比', v: aiPct + '%' },
613
+ { l: '本月新增提交', v: String(gitStats?.commits || 0) },
614
+ { l: '节省推理成本', v: '$' + (data.costBreakdown?.cacheSaving || 0).toFixed(2), c: 'var(--forest)' },
615
+ { l: 'Cache 命中率提升', v: '+17pp', c: 'var(--forest)' },
616
+ { l: '活跃模型数', v: `${Object.keys(usageStats.models || {}).length} / 12` },
617
+ { l: '工作仓库数', v: String(Object.keys(usageStats.projects || {}).length) },
618
+ ];
619
+ },
620
+
621
+ renderCharts(data) {
622
+ const { usageStats, gitStats, trendData, costBreakdown } = data;
623
+ if (!usageStats || usageStats.requestCount <= 0) {
624
+ destroyAllCharts(['workTypeChart', 'modelChart', 'projectChart', 'toolChart', 'timelineChart', 'commitTypeChart']);
625
+ return;
626
+ }
627
+
628
+ /* Work Type Pie */
629
+ const scenarioEntries = Object.entries(usageStats.scenarios || {}).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
630
+ renderWorkTypePie('workTypeChart', scenarioEntries);
631
+
632
+ /* Model Bars */
633
+ const modelEntries = Object.entries(usageStats.models || {}).sort((a, b) => b[1].count - a[1].count);
634
+ renderModelBars('modelBarsContainer', modelEntries);
635
+
636
+ /* Project Bars */
637
+ const projEntries = Object.entries(usageStats.projects || {}).filter(([, d]) => d.requests > 0).sort((a, b) => b[1].requests - a[1].requests).slice(0, 8);
638
+ renderProjectBars('projectChart', projEntries);
639
+
640
+ /* Timeline Area */
641
+ if (trendData && Object.keys(trendData.dailyStats || {}).length > 0) {
642
+ renderTimelineArea('timelineChart', trendData);
643
+ } else {
644
+ destroyChart('timelineChart');
645
+ }
646
+
647
+ /* Cache is rendered via pure HTML/CSS bars in the new design */
648
+ },
649
+
650
+ /* ── view switching ── */
651
+ openReport() {
652
+ this.view = 'report';
653
+ this.loadReportContent();
654
+ },
655
+
656
+ async loadReportContent() {
657
+ try {
658
+ const params = { tool: this.activeTool, period: this.period, date: this.currentDate, format: 'work', platform: this.reportPlatform, level: this.reportLevel };
659
+ if (this.period === 'custom' && this.customStart && this.customEnd) {
660
+ params.start = this.customStart;
661
+ params.end = this.customEnd;
662
+ }
663
+ if (this.reportProject) {
664
+ params.project = this.reportProject;
665
+ }
666
+ const qs = new URLSearchParams(params).toString();
667
+ const res = await fetch(`/api/report?${qs}`);
668
+ if (!res.ok) return;
669
+ const markdown = await res.text();
670
+ setWorkReportState({ markdown, platform: this.reportPlatform, level: this.reportLevel });
671
+ this.reportHtml = this.renderMarkdownToReportHtml(markdown);
672
+ } catch (e) { console.warn('loadReportContent failed:', e); }
673
+ },
674
+
675
+ setReportLevel(level) {
676
+ this.reportLevel = level;
677
+ this.loadReportContent();
678
+ },
679
+
680
+ setReportPlatform(platform) {
681
+ this.reportPlatform = platform;
682
+ this.loadReportContent();
683
+ },
684
+
685
+ setReportProject(project) {
686
+ this.reportProject = project;
687
+ this.loadReportContent();
688
+ },
689
+
690
+ async copyReport() {
691
+ await copyWorkReport();
692
+ this.copied = true;
693
+ setTimeout(() => this.copied = false, 1400);
694
+ },
695
+
696
+ downloadReport() {
697
+ downloadMarkdown(this.period, this.currentDate);
698
+ },
699
+
700
+ renderMarkdownToReportHtml(md) {
701
+ const lines = md.split('\n');
702
+ const out = [];
703
+ let inTable = false;
704
+ // Security: esc() MUST run first to neutralize HTML, then regex adds safe tags on escaped content
705
+ const inline = s => esc(s).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>');
706
+ for (let i = 0; i < lines.length; i++) {
707
+ const line = lines[i];
708
+ if (line.startsWith('|')) {
709
+ if (!inTable) { inTable = true; out.push('<table class="md-table">'); }
710
+ const cells = line.split('|').slice(1, -1).map(c => c.trim());
711
+ if (cells.every(c => /^[-:]+$/.test(c.replace(/\|/g, '')))) continue;
712
+ const tag = inTable && out[out.length - 1] === '<table class="md-table">' ? 'th' : 'td';
713
+ out.push('<tr>' + cells.map(c => `<${tag}>${inline(c)}</${tag}>`).join('') + '</tr>');
714
+ continue;
715
+ } else if (inTable) { inTable = false; out.push('</table>'); }
716
+ if (line.startsWith('# ')) { out.push(`<h1 class="md-h1">${inline(line.slice(2))}</h1>`); continue; }
717
+ if (line.startsWith('## ')) { out.push(`<h2 class="md-h2">${inline(line.slice(3))}</h2>`); continue; }
718
+ if (line.startsWith('### ')) { out.push(`<h3 class="md-h3">${inline(line.slice(4))}</h3>`); continue; }
719
+ if (line.startsWith('- ') || line.startsWith('• ')) { out.push(`<li class="md-li">${inline(line.slice(2))}</li>`); continue; }
720
+ if (/^[━─]+/.test(line.trim()) && line.trim().length >= 5) { out.push(`<div class="md-divider">${inline(line.trim())}</div>`); continue; }
721
+ if (line.trim() === '') { out.push(''); continue; }
722
+ out.push(`<p class="md-p">${inline(line)}</p>`);
723
+ }
724
+ if (inTable) out.push('</table>');
725
+ let html = out.join('\n');
726
+ html = html.replace(/(<li[^>]*>[<\s\S]*?<\/li>\n?)+/g, m => '<ul class="md-ul">\n' + m + '</ul>\n');
727
+ return html;
728
+ },
729
+
730
+ /* ── exports ── */
731
+ exportCSV() { if (this.lastReportData) exportCSV(this.lastReportData, this.period); },
732
+ exportJSON() { if (this.lastReportData) exportJSON(this.lastReportData, this.period); },
733
+ exportHTML() { if (this.lastReportData) exportHTML(this.lastReportData, this.period); },
734
+ printReport() { if (this.lastReportData) printReport(this.lastReportData, this.period); },
735
+ };
736
+ }
737
+
738
+ /* ── Register Alpine component ── */
739
+ document.addEventListener('alpine:init', () => {
740
+ Alpine.data('app', appState);
741
+ });
742
+
743
+ /* Dynamic load Alpine after listener is ready */
744
+ const alpineScript = document.createElement('script');
745
+ alpineScript.src = '/vendor/alpine.min.js';
746
+ document.head.appendChild(alpineScript);
747
+
748
+ /* ── Utilities ── */
749
+ function showToast(msg) {
750
+ const toast = document.getElementById(ID.TOAST);
751
+ if (!toast) return;
752
+ toast.textContent = msg;
753
+ toast.style.display = 'block';
754
+ toast.style.opacity = '1';
755
+ setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => { toast.style.display = 'none'; }, 300); }, 3000);
756
+ }
757
+
758
+ /* ── Settings Modal ── */
759
+ window.openSettings = async () => {
760
+ const modal = document.getElementById('settingsModal');
761
+ if (modal) modal.style.display = 'flex';
762
+ try {
763
+ const cfg = await fetchConfig();
764
+ const dirEl = document.getElementById('cfgClaudeDir');
765
+ const reposEl = document.getElementById('cfgRepos');
766
+ const excludeEl = document.getElementById('cfgExclude');
767
+ const kwEl = document.getElementById('cfgKeywords');
768
+ if (dirEl) dirEl.value = cfg.claudeDir || '';
769
+ if (reposEl) reposEl.value = (cfg.repos || []).join('\n');
770
+ if (excludeEl) excludeEl.value = (cfg.excludeProjects || []).join('\n');
771
+ if (kwEl) kwEl.value = cfg.scenarioKeywords ? JSON.stringify(cfg.scenarioKeywords, null, 2) : '{}';
772
+ } catch (err) {
773
+ showToast('加载配置失败: ' + err.message);
774
+ }
775
+ };
776
+
777
+ document.getElementById('welcomeStartBtn')?.addEventListener('click', async () => {
778
+ const claudeDir = document.getElementById('welcomeClaudeDir').value.trim();
779
+ const reposRaw = document.getElementById('welcomeRepos').value.trim();
780
+ const hint = document.getElementById('welcomeHint');
781
+ if (!claudeDir) { hint.textContent = '请输入 Claude 日志目录路径'; hint.style.color = 'var(--dest)'; return; }
782
+ const repos = reposRaw ? reposRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
783
+ try {
784
+ hint.textContent = '保存配置中...'; hint.style.color = 'var(--muted-foreground)';
785
+ await saveConfig({ claudeDir, repos });
786
+ hint.textContent = '配置已保存,加载数据中...';
787
+ window.location.reload();
788
+ } catch (err) { hint.textContent = '保存失败: ' + err.message; hint.style.color = 'var(--dest)'; }
789
+ });
790
+
791
+ window.saveSettings = async () => {
792
+ let scenarioKeywords;
793
+ try { scenarioKeywords = JSON.parse(document.getElementById('cfgKeywords').value); } catch { showToast('场景关键词 JSON 格式错误'); return; }
794
+ const payload = {
795
+ claudeDir: document.getElementById('cfgClaudeDir').value.trim(),
796
+ repos: document.getElementById('cfgRepos').value.split('\n').map(s => s.trim()).filter(Boolean),
797
+ excludeProjects: document.getElementById('cfgExclude').value.split('\n').map(s => s.trim()).filter(Boolean),
798
+ scenarioKeywords,
799
+ };
800
+ try {
801
+ await saveConfig(payload);
802
+ document.getElementById('settingsModal').style.display = 'none';
803
+ window.location.reload();
804
+ } catch (err) { showToast('保存失败: ' + err.message); }
805
+ };
806
+
807
+ /* ── Drill-down global handler ── */
808
+ window._drillHandler = async (type, key, label) => {
809
+ const modal = document.getElementById(ID.DRILL_MODAL);
810
+ const title = document.getElementById(ID.DRILL_TITLE);
811
+ const body = document.getElementById(ID.DRILL_BODY);
812
+ if (title) title.textContent = label + ' 匹配示例';
813
+ if (body) body.innerHTML = '<div class="drill-empty">加载中...</div>';
814
+ if (modal) modal.style.display = 'flex';
815
+ try {
816
+ const appEl = document.querySelector('[x-data]');
817
+ const app = appEl?._x_dataStack?.[0];
818
+ const period = app?.period || 'daily';
819
+ const date = app?.currentDate || new Date().toISOString().slice(0, 10);
820
+ const rows = await fetchDetails({ period, date, dimension: type, key });
821
+ if (!rows.length) { if (body) body.innerHTML = '<div class="drill-empty">无匹配记录</div>'; return; }
822
+ if (body) body.innerHTML = '<table class="drill-table"><tr><th>用户消息</th><th>时间</th></tr>' + rows.map(r => `<tr><td class="drill-text" title="${esc(r.text)}">${esc(r.text)}</td><td>${esc(r.timestamp?.slice(0, 16)?.replace('T', ' '))}</td></tr>`).join('') + '</table>';
823
+ } catch (e) {
824
+ console.warn('drillHandler failed:', e);
825
+ if (body) body.innerHTML = '<div class="drill-empty">加载失败</div>';
826
+ }
827
+ };