lumencode 1.3.2 → 1.3.4

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,1167 +1,1483 @@
1
- import { COLORS, SCENARIO_COLORS, TEXT, ID, STORAGE } from './config.js';
2
- import { esc, fmt, fmtShort, destroyChart, destroyAllCharts, getChart, setChart, todayISO, fmtDate, TOOL_DISPLAY_NAMES, groupMcpByServer, aggregateToolsWithDualCounts } from './utils.js';
3
- import { createLatestRequestGuard, fetchTools, fetchReport, fetchConfig, saveConfig, fetchDetails, fetchSessions, fetchStepStats, fetchHooksStatus, updateHooks } from './api.js';
4
- import { renderWorkTypePie, renderModelBars, renderProjectBars, renderTimelineArea, renderCacheStack } from './charts.js';
5
- import { renderGitInsights, renderLineBlameEvidence } 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
- lineBlameEvidence: null,
99
- lineBlamePrecision: '',
100
- stepStats: null,
101
- stepStatusLabel: '',
102
- hooksStatus: null,
103
- hooksBusy: false,
104
- gitOutputCells: [
105
- { l: '提交', en: 'COMMITS', v: '-', c: '' },
106
- { l: '变更文件', en: 'FILES', v: '-', c: '' },
107
- { l: '新增', en: '+ ADDED', v: '-', c: 'var(--forest)' },
108
- { l: '删除', en: '− REMOVED', v: '-', c: 'var(--dest)' },
109
- ],
110
- attributionCells: [
111
- { l: 'AI 改写', en: 'REWRITE', v: '-', c: '' },
112
- { l: 'AI 提交', en: 'COMMITS', v: '-', c: 'var(--forest)' },
113
- { l: '可能上限', en: 'MAX', v: '-', c: '' },
114
- { l: '高·中置信', en: 'HI · MID', v: '-', c: 'var(--ochre)' },
115
- { l: 'AI 新增', en: '+ AI', v: '-', c: 'var(--forest)' },
116
- { l: 'AI 删除', en: '− AI', v: '-', c: 'var(--dest)' },
117
- ],
118
-
119
- /* section data defaults */
120
- editTypeData: [],
121
- topFilesData: [],
122
- topFilesMeta: '+0 / −0',
123
- workTypeData: [],
124
- modelData: [],
125
- topModelName: '-',
126
- activeModels: '-',
127
- cacheHitRate: 0,
128
- cacheDelta: '',
129
- cacheData: [],
130
- cacheSavingText: '',
131
- timelineMeta: [
132
- { l: 'PEAK DAY', v: '-', s: '-' },
133
- { l: 'AVG / DAY', v: '-', s: 'sessions' },
134
- { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
135
- { l: 'IDLE DAYS', v: '-', s: 'no activity' },
136
- ],
137
- toolRankData: [],
138
- toolRankTotal: 0,
139
- toolRankTab: 'all',
140
- toolRankTotalCalls: 0,
141
- toolRankAllTotal: 0,
142
- toolRankSkillTotal: 0,
143
- toolRankMcpTotal: 0,
144
- projectData: [],
145
-
146
- /* tool summary for rail */
147
- toolTokens: { all: '-' },
148
- toolSessions: { all: 0 },
149
-
150
- /* report view data */
151
- reportKpis: [
152
- { l: 'TOKENS', v: '-', s: '估算成本 -', accent: false },
153
- { l: 'COMMITS', v: '-', s: '- / - 行', accent: false },
154
- { l: 'AI CONTRIBUTION', v: '-', s: '- 行可独立运行', accent: true },
155
- { l: 'ACTIVE DAYS', v: '-', s: '连续 - 天最长', accent: false },
156
- ],
157
- reportSubTitle: '',
158
- reportSummary: '',
159
- reportHighlights: [],
160
-
161
- get hooksNeedAction() {
162
- if (!this.hooksStatus) return false;
163
- return !this.hooksStatus.stepsInitialized ||
164
- !this.hooksStatus.claude?.enabled ||
165
- !this.hooksStatus.codex?.enabled ||
166
- !this.hooksStatus.opencode?.enabled;
167
- },
168
-
169
- get hooksStatusText() {
170
- if (!this.hooksStatus) return '正在检查 hooks 状态';
171
- const total = this.hooksStatus.projectCount ?? this.hooksStatus.claude?.total ?? 0;
172
- if (this.hooksStatus.targetMode === 'configured-projects') {
173
- if (total === 0) return '未配置项目,请先在设置中添加项目路径';
174
- const parts = [
175
- `Claude ${this.hooksStatus.claude?.enabledCount || 0}/${total}`,
176
- `Codex ${this.hooksStatus.codex?.enabledCount || 0}/${total}`,
177
- `OpenCode ${this.hooksStatus.opencode?.enabledCount || 0}/${total}`,
178
- `steps ${this.hooksStatus.stepsReadyCount || 0}/${total}`,
179
- ];
180
- return `设置内项目 hooks:${parts.join(' / ')}`;
181
- }
182
- const parts = [
183
- `Claude ${this.hooksStatus.claude?.enabled ? '已开启' : '未开启'}`,
184
- `Codex ${this.hooksStatus.codex?.enabled ? '已开启' : '未开启'}`,
185
- `OpenCode ${this.hooksStatus.opencode?.enabled ? '已开启' : '未开启'}`,
186
- `steps ${this.hooksStatus.stepsInitialized ? '已初始化' : '未初始化'}`,
187
- ];
188
- return parts.join(' / ');
189
- },
190
-
191
- /* ── init ── */
192
- async init() {
193
- this.loadStateFromHash();
194
- if (this.theme === 'dark') document.documentElement.classList.add('dark');
195
- else document.documentElement.classList.remove('dark');
196
- this.$watch('view', (value) => {
197
- if (value === 'ledger' && this.lastReportData) {
198
- this.$nextTick(() => this.renderCharts(this.lastReportData));
199
- }
200
- });
201
- await this.loadTools();
202
- await this.loadHooksStatus();
203
- await this.loadStepStats();
204
- // 首次加载时先获取全量数据填充侧边栏,再按当前工具加载
205
- if (this.activeTool !== 'all') {
206
- try {
207
- const allData = await fetchReport({ tool: 'all', period: this.period, date: this.currentDate });
208
- if (allData && !allData.error) {
209
- this.computeToolTokens(allData.usageStats, allData.toolBreakdown);
210
- }
211
- } catch {}
212
- }
213
- await this.loadCurrentView();
214
- },
215
-
216
- /* ── theme ── */
217
- toggleTheme() {
218
- this.theme = this.theme === 'dark' ? 'light' : 'dark';
219
- localStorage.setItem(STORAGE.THEME, this.theme);
220
- if (this.theme === 'dark') document.documentElement.classList.add('dark');
221
- else document.documentElement.classList.remove('dark');
222
- /* re-render charts to pick up new colors */
223
- if (this.lastReportData && this.view === 'ledger') this.renderCharts(this.lastReportData);
224
- },
225
-
226
- /* ── tools ── */
227
- async loadTools() {
228
- try {
229
- const data = await fetchTools();
230
- this.availableTools = data.tools || data || [];
231
- if (data.appName) this.appName = data.appName;
232
- if (data.appVersion) this.appVersion = data.appVersion;
233
- } catch (e) { console.warn('loadTools failed:', e); this.availableTools = []; }
234
- },
235
-
236
- async loadStepStats() {
237
- try {
238
- const data = await fetchStepStats();
239
- this.stepStats = data;
240
- this.stepStatusLabel = data?.available
241
- ? `STEP READY · ${data.stepCount || 0}`
242
- : 'STEP NOT READY';
243
- } catch {
244
- this.stepStats = null;
245
- this.stepStatusLabel = '';
246
- }
247
- },
248
-
249
- async loadHooksStatus() {
250
- try {
251
- this.hooksStatus = await fetchHooksStatus();
252
- } catch (e) {
253
- console.warn('loadHooksStatus failed:', e);
254
- this.hooksStatus = null;
255
- }
256
- },
257
-
258
- showHooksConfirmModal() {
259
- const count = this.hooksStatus?.projectCount ?? 0;
260
- const el = document.getElementById('hooksConfirmCount');
261
- if (el) el.textContent = count;
262
- const modal = document.getElementById('hooksConfirmModal');
263
- if (modal) modal.style.display = 'flex';
264
- },
265
-
266
- hideHooksConfirmModal() {
267
- const modal = document.getElementById('hooksConfirmModal');
268
- if (modal) modal.style.display = 'none';
269
- },
270
-
271
- async enableHooksFromUi() {
272
- this.hideHooksConfirmModal();
273
- if (this.hooksBusy) return;
274
- this.hooksBusy = true;
275
- try {
276
- await updateHooks('enable');
277
- await this.loadHooksStatus();
278
- await this.loadStepStats();
279
- showToast('hooks 已开启');
280
- } catch (err) {
281
- showToast('开启 hooks 失败: ' + err.message);
282
- } finally {
283
- this.hooksBusy = false;
284
- }
285
- },
286
-
287
- showHooksDisableConfirmModal() {
288
- const modal = document.getElementById('hooksDisableConfirmModal');
289
- if (modal) modal.style.display = 'flex';
290
- },
291
-
292
- hideHooksDisableConfirmModal() {
293
- const modal = document.getElementById('hooksDisableConfirmModal');
294
- if (modal) modal.style.display = 'none';
295
- },
296
-
297
- async disableHooksFromUi() {
298
- this.hideHooksDisableConfirmModal();
299
- if (this.hooksBusy) return;
300
- this.hooksBusy = true;
301
- try {
302
- await updateHooks('disable');
303
- await this.loadHooksStatus();
304
- await this.loadStepStats();
305
- showToast('hooks 已关闭');
306
- } catch (err) {
307
- showToast('关闭 hooks 失败: ' + err.message);
308
- } finally {
309
- this.hooksBusy = false;
310
- }
311
- },
312
-
313
- setTool(name) {
314
- this.activeTool = name;
315
- this.loadCurrentView();
316
- if (this.view === 'report') this.loadReportContent();
317
- },
318
-
319
- setToolRankTab(tab) {
320
- this.toolRankTab = tab;
321
- this._computeToolRank();
322
- const container = document.getElementById('toolCallsContainer');
323
- if (container) container.scrollTop = 0;
324
- },
325
-
326
- _computeToolRank() {
327
- const usageStats = this._lastUsageStats || {};
328
- const tab = this.toolRankTab;
329
- const getCalls = (val) => typeof val === 'number' ? val : (val.calls || 0);
330
- const getUses = (val) => typeof val === 'number' ? val : (val.uses || 0);
331
- const sumCalls = (obj) => Object.values(obj || {}).reduce((s, v) => s + getCalls(v), 0);
332
-
333
- this.toolRankAllTotal = sumCalls(usageStats.tools);
334
- this.toolRankSkillTotal = sumCalls(usageStats.skills);
335
- this.toolRankMcpTotal = sumCalls(usageStats.mcpTools);
336
-
337
- if (tab === 'all') {
338
- const dual = aggregateToolsWithDualCounts(usageStats.tools || {});
339
- const entries = Object.entries(dual).sort((a, b) => b[1].uses - a[1].uses);
340
- const maxUses = Math.max(...entries.map(([, v]) => v.uses), 1);
341
- this.toolRankData = entries.map(([name, d]) => ({
342
- name,
343
- calls: d.calls,
344
- uses: d.uses,
345
- value: d.calls,
346
- pct: Math.round((d.uses / maxUses) * 100),
347
- displayName: TOOL_DISPLAY_NAMES[name] || '',
348
- }));
349
- this.toolRankTotalCalls = this.toolRankAllTotal;
350
- } else if (tab === 'skill') {
351
- const skills = usageStats.skills || {};
352
- const entries = Object.entries(skills).sort((a, b) => getUses(b[1]) - getUses(a[1]));
353
- const maxUses = Math.max(...entries.map(([, v]) => getUses(v)), 1);
354
- this.toolRankData = entries.map(([name, val]) => {
355
- const calls = getCalls(val);
356
- const uses = getUses(val);
357
- return {
358
- name,
359
- calls,
360
- uses,
361
- value: calls,
362
- pct: Math.round((uses / maxUses) * 100),
363
- displayName: '',
364
- };
365
- });
366
- this.toolRankTotalCalls = this.toolRankSkillTotal;
367
- } else if (tab === 'mcp') {
368
- this.toolRankData = groupMcpByServer(usageStats.mcpTools || {});
369
- this.toolRankTotalCalls = this.toolRankMcpTotal;
370
- }
371
-
372
- this.toolRankTotal = this.toolRankData.length;
373
- let rank = 0;
374
- for (const item of this.toolRankData) {
375
- if (!item.isGroup) item.rank = ++rank;
376
- }
377
- },
378
-
379
- /* ── period / date ── */
380
- setPeriod(p) {
381
- this.period = p;
382
- if (p !== 'custom') {
383
- this.customStart = '';
384
- this.customEnd = '';
385
- this.saveStateToHash();
386
- this.loadCurrentView();
387
- if (this.view === 'report') this.loadReportContent();
388
- }
389
- },
390
-
391
- onCustomStartChange() {
392
- if (this.customStart && this.customEnd) {
393
- this.loadCurrentView();
394
- if (this.view === 'report') this.loadReportContent();
395
- }
396
- },
397
-
398
- onCustomEndChange() {
399
- if (this.customStart && this.customEnd) {
400
- this.loadCurrentView();
401
- if (this.view === 'report') this.loadReportContent();
402
- }
403
- },
404
-
405
- shiftDate(dir) {
406
- const d = new Date(this.currentDate);
407
- if (this.period === 'monthly') {
408
- d.setMonth(d.getMonth() + dir);
409
- } else {
410
- const step = this.period === 'weekly' ? 7 * dir : dir;
411
- d.setDate(d.getDate() + step);
412
- }
413
- this.currentDate = d.toISOString().slice(0, 10);
414
- this.saveStateToHash();
415
- this.loadCurrentView();
416
- if (this.view === 'report') this.loadReportContent();
417
- },
418
-
419
- onDateChange() {
420
- if (this.currentDate > this.today) this.currentDate = this.today;
421
- this.saveStateToHash();
422
- this.loadCurrentView();
423
- if (this.view === 'report') this.loadReportContent();
424
- },
425
-
426
- loadStateFromHash() {
427
- const hash = location.hash.slice(1);
428
- if (!hash) return;
429
- const [p, d] = hash.split('/');
430
- if (p && ['daily', 'weekly', 'monthly', 'custom'].includes(p)) this.period = p;
431
- if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) this.currentDate = d;
432
- },
433
-
434
- saveStateToHash() {
435
- location.hash = `${this.period}/${this.currentDate}`;
436
- },
437
-
438
- /* ── data loading ── */
439
- async loadCurrentView() {
440
- const cacheKey = `${this.activeTool}-${this.period}-${this.period === 'custom' ? this.customStart + '~' + this.customEnd : this.currentDate}`;
441
- const request = this.reportRequestGuard.next();
442
-
443
- if (this.cache[cacheKey]) {
444
- const idx = this._cacheOrder.indexOf(cacheKey);
445
- if (idx !== -1) { this._cacheOrder.splice(idx, 1); this._cacheOrder.push(cacheKey); }
446
- this.renderData(this.cache[cacheKey]);
447
- this.loading = false;
448
- return;
449
- }
450
-
451
- this.loading = true;
452
- this.error = null;
453
-
454
- try {
455
- const params = { tool: this.activeTool, period: this.period, date: this.currentDate };
456
- if (this.period === 'custom' && this.customStart && this.customEnd) {
457
- params.start = this.customStart;
458
- params.end = this.customEnd;
459
- }
460
- const data = await fetchReport(params, request.signal);
461
- if (!request.isCurrent()) return;
462
-
463
- if (!data || data.error) {
464
- this.hasData = false;
465
- if (data?.error === TEXT.NOT_CONFIGURED) {
466
- this.showWelcome();
467
- }
468
- return;
469
- }
470
-
471
- this.hideWelcome();
472
- this.cache[cacheKey] = data;
473
- this._cacheOrder.push(cacheKey);
474
- while (this._cacheOrder.length > this._cacheMaxSize) {
475
- const old = this._cacheOrder.shift();
476
- delete this.cache[old];
477
- }
478
- this.lastReportData = data;
479
- this.renderData(data);
480
- } catch (err) {
481
- if (err.name === 'AbortError') return;
482
- this.error = err.message;
483
- showToast('加载失败: ' + err.message);
484
- } finally {
485
- if (request.isCurrent()) this.loading = false;
486
- }
487
- },
488
-
489
- showWelcome() {
490
- const wp = document.getElementById(ID.WELCOME_PAGE);
491
- if (wp) wp.style.display = 'flex';
492
- },
493
-
494
- hideWelcome() {
495
- const wp = document.getElementById(ID.WELCOME_PAGE);
496
- if (wp) wp.style.display = 'none';
497
- },
498
-
499
- /* ── render data ── */
500
- renderData(data) {
501
- const { usageStats, gitStats, start, end, prevStats, trendData, costBreakdown } = data;
502
- this._lastUsageStats = usageStats;
503
- this.hasData = usageStats.requestCount > 0;
504
- if (!this.hasData) {
505
- this.kpiData = [
506
- { label: '活跃天数', sub: 'ACTIVE DAYS', value: '-', unit: '/ ' + (this.period === 'daily' ? '1' : this.period === 'weekly' ? '7' : '31'), delta: '', trend: 'flat' },
507
- { label: '覆盖项目', sub: 'PROJECTS', value: '0', unit: '个', delta: '', trend: 'flat' },
508
- { label: '高峰天数', sub: 'PEAK DAYS', value: '-', unit: '天', delta: '', trend: 'flat' },
509
- { label: 'Token 消耗 · 含缓存', sub: 'TOKENS · INC. CACHE', value: '0.00', unit: 'M', delta: '', trend: 'flat' },
510
- { label: '估算成本', sub: 'EST. COST USD', value: '$0.00', unit: '', delta: '', trend: 'flat' },
511
- ];
512
- destroyAllCharts(['workTypeChart', 'modelChart', 'projectChart', 'toolChart', 'timelineChart', 'commitTypeChart', 'cacheChart']);
513
- return;
514
- }
515
-
516
- /* KPI strip */
517
- const days = Object.keys(usageStats.dailyStats || {}).length || 1;
518
- const totalMin = Math.round((usageStats.requestCount || 0) * 2.4);
519
- const peakDay = Object.entries(usageStats.dailyStats || {}).sort((a, b) => (b[1].requests || 0) - (a[1].requests || 0))[0];
520
- const tokensM = (usageStats.totalTokens / 1_000_000).toFixed(2);
521
- const cost = usageStats.estimatedCost || 0;
522
- const prevCost = prevStats?.estimatedCost || 0;
523
- const costDelta = prevCost > 0 ? ((cost - prevCost) / prevCost * 100).toFixed(1) : 0;
524
- const costTrend = cost > prevCost ? 'up' : cost < prevCost ? 'down' : 'flat';
525
-
526
- this.kpiData = [
527
- { label: '活跃天数', sub: 'ACTIVE DAYS', value: String(days), unit: '/ ' + (this.period === 'daily' ? '1' : this.period === 'weekly' ? '7' : '31'), delta: '', trend: 'flat' },
528
- { label: '覆盖项目', sub: 'PROJECTS', value: String(Object.keys(usageStats.projects || {}).length), unit: '个', delta: '', trend: 'flat' },
529
- { label: '高峰天数', sub: 'PEAK DAYS', value: peakDay ? peakDay[0].slice(5) : '-', unit: '天', delta: '', trend: 'flat' },
530
- { label: 'Token 消耗 · 含缓存', sub: 'TOKENS · INC. CACHE', value: tokensM, unit: 'M', delta: '', trend: 'flat' },
531
- { label: '估算成本', sub: 'EST. COST USD', value: '$' + cost.toFixed(2), unit: '', delta: (costDelta > 0 ? '+' : '') + costDelta + '%', trend: costTrend },
532
- ];
533
-
534
- /* AI contribution */
535
- this.renderAIContribution(gitStats, usageStats);
536
-
537
- /* Edit types (commit types) */
538
- const typeEntries = gitStats?.commitTypes ? Object.entries(gitStats.commitTypes).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]) : [];
539
- const maxType = Math.max(...typeEntries.map(([, v]) => v), 1);
540
- const inkSteps = ['var(--rust)', 'var(--ink-82)', 'var(--ink-62)', 'var(--ink-46)', 'var(--ink-32)', 'var(--ink-22)'];
541
- this.editTypeData = typeEntries.map(([name, value], idx) => ({
542
- name, value, pct: Math.round((value / maxType) * 100),
543
- color: inkSteps[Math.min(idx, inkSteps.length - 1)],
544
- }));
545
-
546
- /* Top files */
547
- const hotspots = gitStats?.fileHotspots || [];
548
- this.topFilesData = hotspots.slice(0, 10).map(h => ({ path: h.path, commits: h.touches, plus: h.added, minus: h.deleted }));
549
- const totalAdded = hotspots.reduce((s, h) => s + (h.added || 0), 0);
550
- const totalDeleted = hotspots.reduce((s, h) => s + (h.deleted || 0), 0);
551
- this.topFilesMeta = `+${fmt(totalAdded)} / −${fmt(totalDeleted)}`;
552
-
553
- /* Work type (scenarios) */
554
- const scenarioEntries = Object.entries(usageStats.scenarios || {}).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
555
- const totalScenario = scenarioEntries.reduce((s, [, v]) => s + v, 0) || 1;
556
- this.workTypeData = scenarioEntries.map(([name, value], i) => ({
557
- name, value: Math.round((value / totalScenario) * 100),
558
- color: SCENARIO_COLORS[name] || '#888',
559
- hidden: false,
560
- }));
561
-
562
- /* Models */
563
- const modelEntries = Object.entries(usageStats.models || {}).sort((a, b) => b[1].count - a[1].count);
564
- const maxModel = Math.max(...modelEntries.map(([, v]) => v.count), 1);
565
- const totalModelReq = modelEntries.reduce((s, [, v]) => s + v.count, 0) || 1;
566
- this.modelData = modelEntries.map(([name, d]) => ({
567
- name, pct: Math.round((d.count / totalModelReq) * 100),
568
- barPct: Math.round((d.count / maxModel) * 100),
569
- }));
570
- this.topModelName = modelEntries[0]?.[0] || '-';
571
- this.activeModels = `${modelEntries.length} / 12`;
572
-
573
- /* Cache */
574
- const cacheRead = usageStats.cacheRead || 0;
575
- const cacheCreate = usageStats.cacheCreate || 0;
576
- const inputTok = usageStats.inputTokens || 1;
577
- const cacheTotal = cacheRead + cacheCreate + inputTok;
578
- this.cacheHitRate = cacheTotal > 0 ? Math.round((cacheRead / cacheTotal) * 100) : 0;
579
- this.cacheDelta = cacheRead > 0 ? '+17pp' : '';
580
- this.cacheData = [
581
- { label: '命中', en: 'HIT', value: this.cacheHitRate, color: 'var(--forest)' },
582
- { label: '未命中', en: 'MISS', value: cacheTotal > 0 ? Math.round((inputTok / cacheTotal) * 100) : 0, color: 'var(--ochre)' },
583
- { label: '未启用', en: 'OFF', value: cacheTotal > 0 ? Math.max(0, 100 - this.cacheHitRate - Math.round((inputTok / cacheTotal) * 100)) : 0, color: 'var(--clay)' },
584
- ];
585
- const saving = costBreakdown?.cacheSaving || 0;
586
- this.cacheSavingText = saving > 0 ? `本月缓存命中节省 <span class="font-mono" style="color:var(--forest)">$${saving.toFixed(2)}</span> ≈ 总成本 ${((saving / Math.max(cost, 1)) * 100).toFixed(1)}%` : '';
587
-
588
- /* Timeline */
589
- this.renderTimeline(trendData, usageStats);
590
-
591
- /* Projects */
592
- const projEntries = Object.entries(usageStats.projects || {}).filter(([, d]) => d.requests > 0).sort((a, b) => b[1].requests - a[1].requests).slice(0, 8);
593
- this.projectData = projEntries.map(([name, d]) => ({ name: name.length > 20 ? '...' + name.slice(-17) : name, value: d.requests }));
594
-
595
- /* Tool rank */
596
- this._computeToolRank();
597
-
598
- /* Tool rail tokens — only refresh sidebar when viewing all tools */
599
- if (this.activeTool === 'all') {
600
- this.computeToolTokens(usageStats, data.toolBreakdown);
601
- }
602
-
603
- /* Git insights (existing chart + table) */
604
- if (gitStats && (gitStats.commits > 0 || gitStats.filesChanged > 0)) {
605
- renderGitInsights(gitStats, this.activeTool);
606
- }
607
-
608
- /* Report view data pre-compute */
609
- this.computeReportData(data);
610
-
611
- /* Project list for report view */
612
- this.reportProjects = Object.keys(data.projectDetails || {}).sort();
613
-
614
- /* Charts (Chart.js) */
615
- this.$nextTick(() => this.renderCharts(data));
616
- },
617
-
618
- toggleWorkType(idx) {
619
- const item = this.workTypeData[idx];
620
- if (!item) return;
621
- item.hidden = !item.hidden;
622
- const chart = getChart('workTypeChart');
623
- if (chart) {
624
- chart.toggleDataVisibility(idx);
625
- chart.update();
626
- }
627
- },
628
-
629
- _animatePct(target) {
630
- if (this._aiPctAnim) cancelAnimationFrame(this._aiPctAnim);
631
- const start = this.aiLinePctDisplay || 0;
632
- const duration = 800;
633
- const t0 = performance.now();
634
- const tick = (now) => {
635
- const elapsed = now - t0;
636
- const progress = Math.min(elapsed / duration, 1);
637
- const eased = 1 - Math.pow(1 - progress, 3);
638
- this.aiLinePctDisplay = Math.round(start + (target - start) * eased);
639
- if (progress < 1) this._aiPctAnim = requestAnimationFrame(tick);
640
- };
641
- this._aiPctAnim = requestAnimationFrame(tick);
642
- },
643
-
644
- renderAIContribution(gitStats, usageStats) {
645
- const ai = gitStats?.aiContribution;
646
- if (!ai || !gitStats || gitStats.commits <= 0) {
647
- this.aiLinePct = 0;
648
- this.aiLinePctDisplay = 0;
649
- if (this._aiPctAnim) cancelAnimationFrame(this._aiPctAnim);
650
- this.aiSummaryDesc = '暂无 Git 数据';
651
- return;
652
- }
653
- const totalLines = ai.totalLinesChanged || (ai.aiFileLinesAdded + ai.aiFileLinesDeleted + (ai.humanLinesChanged || 0)) || 1;
654
- const targetPct = Math.round((ai.aiLinesChanged / totalLines) * 100) || Math.round((ai.aiLineRatio || 0) * 100);
655
- this.aiLinePct = targetPct;
656
- this._animatePct(targetPct);
657
- this.aiContributionMeta = `${fmt(ai.aiLinesChanged || 0)} / ${fmt(totalLines)} LINES`;
658
-
659
- if (gitStats.attributionSummary) {
660
- const s = gitStats.attributionSummary;
661
- const upperPct = Math.round(((s.confirmedAILines + s.probableAILines + s.possibleAILines) / (s.totalLinesChanged || 1)) * 100);
662
- const weightedPct = Math.round((ai.weightedAILineRatio || 0) * 100);
663
- let desc = '代码变更有 AI 参与';
664
- if (ai.possibleAICommits > 0) {
665
- desc += `,可能 AI 影响 <strong>${ai.possibleAICommits}</strong> 提交`;
666
- }
667
- if (weightedPct > targetPct) {
668
- desc += `,加权影响力 <strong>${weightedPct}%</strong>`;
669
- }
670
- this.aiSummaryDesc = desc;
671
- this.confirmedPct = Math.round((s.confirmedAILines / (s.totalLinesChanged || 1)) * 100);
672
- this.inferredPct = Math.round((s.probableAILines / (s.totalLinesChanged || 1)) * 100);
673
- this.unattribPct = Math.max(0, 100 - this.confirmedPct - this.inferredPct);
674
- this.attributionPct = `${this.confirmedPct}% / ${upperPct}%`;
675
- } else {
676
- this.aiSummaryDesc = '代码变更有 AI 参与';
677
- this.confirmedPct = this.aiLinePct;
678
- this.inferredPct = 0;
679
- this.unattribPct = 100 - this.aiLinePct;
680
- this.attributionPct = `${this.aiLinePct}% / 100%`;
681
- }
682
-
683
- const commitPct = Math.round((ai.aiCommits / gitStats.commits) * 100);
684
- this.gitOutputCells = [
685
- { l: '提交', en: 'COMMITS', v: String(gitStats.commits), c: '' },
686
- { l: '变更文件', en: 'FILES', v: String(gitStats.filesChanged), c: '' },
687
- { l: '新增', en: '+ ADDED', v: '+' + fmt(gitStats.linesAdded), c: 'var(--forest)' },
688
- { l: '删除', en: '− REMOVED', v: '−' + fmt(gitStats.linesDeleted), c: 'var(--dest)' },
689
- ];
690
- this.attributionCells = [
691
- { l: 'AI 改写', en: 'REWRITE', v: this.aiLinePct + '%', c: '' },
692
- { l: 'AI 提交', en: 'COMMITS', v: `${ai.aiCommits}/${gitStats.commits}`, c: 'var(--forest)' },
693
- { l: '可能上限', en: 'MAX', v: (this.confirmedPct + this.inferredPct) + '%', c: '' },
694
- { l: '高·中置信', en: 'HI · MID', v: `${ai.highConfidenceCommits}/${ai.mediumConfidenceCommits}`, c: 'var(--ochre)' },
695
- { l: 'AI 新增', en: '+ AI', v: '+' + fmt(ai.aiFileLinesAdded), c: 'var(--forest)' },
696
- { l: 'AI 删除', en: '− AI', v: '−' + fmt(ai.aiFileLinesDeleted), c: 'var(--dest)' },
697
- ];
698
-
699
- /* Source breakdown from real toolBreakdown data */
700
- const toolTokMap = {};
701
- const toolColors = { claude: 'var(--claude)', codex: 'var(--codex)', opencode: 'var(--opencode)' };
702
- const toolDisplayNames = { claude: 'Claude Code', codex: 'OpenAI Codex', opencode: 'OpenCode' };
703
- if (usageStats.toolBreakdown) {
704
- for (const [k, v] of Object.entries(usageStats.toolBreakdown)) {
705
- toolTokMap[k] = (v.inputTokens || 0) + (v.outputTokens || 0);
706
- }
707
- }
708
- const entries = Object.entries(toolTokMap).filter(([, v]) => v > 0);
709
- const totalToolTok = entries.reduce((s, [, v]) => s + v, 0) || 1;
710
- const sorted = entries.sort((a, b) => b[1] - a[1]);
711
- let pctSum = 0;
712
- this.sourceBreakdown = sorted.map(([name, tok], i) => {
713
- const isLast = i === sorted.length - 1;
714
- const pct = isLast ? Math.max(0, 100 - pctSum) : Math.round((tok / totalToolTok) * 100);
715
- pctSum += pct;
716
- return { name: toolDisplayNames[name] || name, pct, tokens: fmtShort(tok), color: toolColors[name] || 'var(--foreground)' };
717
- });
718
-
719
- /* Line-level blame evidence */
720
- const blameEv = renderLineBlameEvidence(gitStats?.commitList);
721
- if (blameEv) {
722
- this.lineBlameEvidence = blameEv;
723
- this.lineBlamePrecision = `行级归因: ${blameEv.aiLines}/${blameEv.totalLines} (${blameEv.precision}%) · ${blameEv.commitCount} 提交`;
724
- } else {
725
- this.lineBlameEvidence = null;
726
- this.lineBlamePrecision = '';
727
- }
728
- },
729
-
730
- renderTimeline(trendData, usageStats) {
731
- const dailyStats = trendData?.dailyStats || {};
732
- const dates = Object.keys(dailyStats).sort();
733
- if (dates.length === 0) {
734
- this.timelineMeta = [
735
- { l: 'PEAK DAY', v: '-', s: '-' },
736
- { l: 'AVG / DAY', v: '-', s: 'sessions' },
737
- { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
738
- { l: 'IDLE DAYS', v: '-', s: 'no activity' },
739
- ];
740
- return;
741
- }
742
- const sessionsArr = dates.map(d => dailyStats[d].requests || 0);
743
- const tokensArr = dates.map(d => ((dailyStats[d].inputTokens || 0) + (dailyStats[d].outputTokens || 0)) / 1_000_000);
744
- const maxSess = Math.max(...sessionsArr);
745
- const maxIdx = sessionsArr.indexOf(maxSess);
746
- const avgSess = (sessionsArr.reduce((s, v) => s + v, 0) / sessionsArr.length).toFixed(1);
747
- this.timelineMeta = [
748
- { l: 'PEAK DAY', v: dates[maxIdx]?.slice(5).replace('-', '.') || '-', s: maxSess + ' sessions' },
749
- { l: 'AVG / DAY', v: avgSess, s: 'sessions' },
750
- { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
751
- { l: 'IDLE DAYS', v: '-', s: 'no activity' },
752
- ];
753
- },
754
-
755
- computeToolTokens(usageStats, toolBreakdown) {
756
- if (!toolBreakdown || Object.keys(toolBreakdown).length === 0) {
757
- const total = usageStats.totalTokens || 0;
758
- this.toolTokens = { all: total >= 1_000_000 ? (total / 1_000_000).toFixed(2) + 'M' : fmtShort(total) };
759
- this.toolSessions = { all: usageStats.sessionCount || 0 };
760
- return;
761
- }
762
- // toolBreakdown 聚合计算 all 值,确保与各工具之和一致
763
- let allTok = 0;
764
- let allSess = 0;
765
- for (const [name, data] of Object.entries(toolBreakdown)) {
766
- const tok = (data.inputTokens || 0) + (data.outputTokens || 0) + (data.cacheRead || 0) + (data.cacheCreate || 0);
767
- allTok += tok;
768
- const sess = data.sessionCount || data.sessions || 0;
769
- allSess += sess;
770
- this.toolTokens[name] = tok >= 1_000_000 ? (tok / 1_000_000).toFixed(2) + 'M' : fmtShort(tok);
771
- this.toolSessions[name] = sess;
772
- }
773
- this.toolTokens.all = allTok >= 1_000_000 ? (allTok / 1_000_000).toFixed(2) + 'M' : fmtShort(allTok);
774
- this.toolSessions.all = allSess;
775
- },
776
-
777
- computeReportData(data) {
778
- const { usageStats, gitStats, start, end, prevStats } = data;
779
- const cost = usageStats.estimatedCost || 0;
780
- const ai = gitStats?.aiContribution;
781
- const aiPct = ai ? Math.round((ai.aiLinesChanged / (ai.totalLinesChanged || 1)) * 100) : 0;
782
- const weightedPct = ai ? Math.round((ai.weightedAILineRatio || 0) * 100) : 0;
783
- const days = Object.keys(usageStats.dailyStats || {}).length || 1;
784
- let aiSubText = `${fmt(ai?.aiLinesChanged || 0)} 行严格可认定`;
785
- if (ai?.possibleAICommits > 0) {
786
- aiSubText += `,${ai.possibleAICommits} 提交可能 AI 参与`;
787
- }
788
- this.reportKpis = [
789
- { l: 'TOKENS', v: (usageStats.totalTokens / 1_000_000).toFixed(2) + 'M', s: `估算成本 $${cost.toFixed(2)}`, accent: false },
790
- { l: 'COMMITS', v: String(gitStats?.commits || 0), s: `+${fmt(gitStats?.linesAdded || 0)} / −${fmt(gitStats?.linesDeleted || 0)} 行`, accent: false },
791
- { l: 'AI CONTRIBUTION', v: aiPct + '%', s: aiSubText, accent: true },
792
- { l: 'ACTIVE DAYS', v: days + ' / ' + (this.period === 'weekly' ? '7' : '31'), s: '连续 - 天最长', accent: false },
793
- ];
794
- this.reportSubTitle = `生成 ${start}${end !== start ? ' ~ ' + end : ''} · 来源 ${this.availableTools.length + 1} 个工具`;
795
- 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>`;
796
- if (weightedPct > aiPct) {
797
- summaryText += `,加权 AI 影响力 ${weightedPct}%`;
798
- }
799
- summaryText += '';
800
- this.reportSummary = summaryText;
801
- this.reportHighlights = [
802
- { l: 'AI 主导编辑占比', v: aiPct + '%' },
803
- { l: '本月新增提交', v: String(gitStats?.commits || 0) },
804
- { l: '节省推理成本', v: '$' + (data.costBreakdown?.cacheSaving || 0).toFixed(2), c: 'var(--forest)' },
805
- { l: 'Cache 命中率提升', v: '+17pp', c: 'var(--forest)' },
806
- { l: '活跃模型数', v: `${Object.keys(usageStats.models || {}).length} / 12` },
807
- { l: '工作仓库数', v: String(Object.keys(usageStats.projects || {}).length) },
808
- ];
809
- },
810
-
811
- renderCharts(data) {
812
- const { usageStats, gitStats, trendData, costBreakdown } = data;
813
- if (!usageStats || usageStats.requestCount <= 0) {
814
- destroyAllCharts(['workTypeChart', 'modelChart', 'projectChart', 'toolChart', 'timelineChart', 'commitTypeChart']);
815
- return;
816
- }
817
-
818
- /* Work Type Pie */
819
- const scenarioEntries = Object.entries(usageStats.scenarios || {}).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
820
- renderWorkTypePie('workTypeChart', scenarioEntries);
821
-
822
- /* Model Bars */
823
- const modelEntries = Object.entries(usageStats.models || {}).sort((a, b) => b[1].count - a[1].count);
824
- renderModelBars('modelBarsContainer', modelEntries);
825
-
826
- /* Project Bars */
827
- const projEntries = Object.entries(usageStats.projects || {}).filter(([, d]) => d.requests > 0).sort((a, b) => b[1].requests - a[1].requests).slice(0, 8);
828
- renderProjectBars('projectChart', projEntries);
829
-
830
- /* Timeline Area */
831
- if (trendData && Object.keys(trendData.dailyStats || {}).length > 0) {
832
- renderTimelineArea('timelineChart', trendData);
833
- } else {
834
- destroyChart('timelineChart');
835
- }
836
-
837
- /* Cache is rendered via pure HTML/CSS bars in the new design */
838
- },
839
-
840
- /* ── view switching ── */
841
- openReport() {
842
- this.view = 'report';
843
- this.loadReportContent();
844
- },
845
-
846
- async loadReportContent() {
847
- try {
848
- const params = { tool: this.activeTool, period: this.period, date: this.currentDate, format: 'work', platform: this.reportPlatform, level: this.reportLevel };
849
- if (this.period === 'custom' && this.customStart && this.customEnd) {
850
- params.start = this.customStart;
851
- params.end = this.customEnd;
852
- }
853
- if (this.reportProject) {
854
- params.project = this.reportProject;
855
- }
856
- const qs = new URLSearchParams(params).toString();
857
- const res = await fetch(`/api/report?${qs}`);
858
- if (!res.ok) return;
859
- const markdown = await res.text();
860
- setWorkReportState({ markdown, platform: this.reportPlatform, level: this.reportLevel });
861
- this.reportHtml = this.renderMarkdownToReportHtml(markdown);
862
- } catch (e) { console.warn('loadReportContent failed:', e); }
863
- },
864
-
865
- setReportLevel(level) {
866
- this.reportLevel = level;
867
- this.loadReportContent();
868
- },
869
-
870
- setReportPlatform(platform) {
871
- this.reportPlatform = platform;
872
- this.loadReportContent();
873
- },
874
-
875
- setReportProject(project) {
876
- this.reportProject = project;
877
- this.loadReportContent();
878
- },
879
-
880
- async copyReport() {
881
- await copyWorkReport();
882
- this.copied = true;
883
- setTimeout(() => this.copied = false, 1400);
884
- },
885
-
886
- downloadReport() {
887
- downloadMarkdown(this.period, this.currentDate);
888
- },
889
-
890
- renderMarkdownToReportHtml(md) {
891
- const lines = md.split('\n');
892
- const out = [];
893
- let inTable = false;
894
- // Security: esc() MUST run first to neutralize HTML, then regex adds safe tags on escaped content
895
- const inline = s => esc(s).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>');
896
- for (let i = 0; i < lines.length; i++) {
897
- const line = lines[i];
898
- if (line.startsWith('|')) {
899
- if (!inTable) { inTable = true; out.push('<table class="md-table">'); }
900
- const cells = line.split('|').slice(1, -1).map(c => c.trim());
901
- if (cells.every(c => /^[-:]+$/.test(c.replace(/\|/g, '')))) continue;
902
- const tag = inTable && out[out.length - 1] === '<table class="md-table">' ? 'th' : 'td';
903
- out.push('<tr>' + cells.map(c => `<${tag}>${inline(c)}</${tag}>`).join('') + '</tr>');
904
- continue;
905
- } else if (inTable) { inTable = false; out.push('</table>'); }
906
- if (line.startsWith('# ')) { out.push(`<h1 class="md-h1">${inline(line.slice(2))}</h1>`); continue; }
907
- if (line.startsWith('## ')) { out.push(`<h2 class="md-h2">${inline(line.slice(3))}</h2>`); continue; }
908
- if (line.startsWith('### ')) { out.push(`<h3 class="md-h3">${inline(line.slice(4))}</h3>`); continue; }
909
- if (line.startsWith('- ') || line.startsWith('• ')) { out.push(`<li class="md-li">${inline(line.slice(2))}</li>`); continue; }
910
- if (/^[━─]+/.test(line.trim()) && line.trim().length >= 5) { out.push(`<div class="md-divider">${inline(line.trim())}</div>`); continue; }
911
- if (line.trim() === '') { out.push(''); continue; }
912
- out.push(`<p class="md-p">${inline(line)}</p>`);
913
- }
914
- if (inTable) out.push('</table>');
915
- let html = out.join('\n');
916
- html = html.replace(/(<li[^>]*>[<\s\S]*?<\/li>\n?)+/g, m => '<ul class="md-ul">\n' + m + '</ul>\n');
917
- return html;
918
- },
919
-
920
- /* ── exports ── */
921
- exportCSV() { if (this.lastReportData) exportCSV(this.lastReportData, this.period); },
922
- exportJSON() { if (this.lastReportData) exportJSON(this.lastReportData, this.period); },
923
- exportHTML() { if (this.lastReportData) exportHTML(this.lastReportData, this.period); },
924
- printReport() { if (this.lastReportData) printReport(this.lastReportData, this.period); },
925
- };
926
- }
927
-
928
- /* ── Register Alpine component ── */
929
- document.addEventListener('alpine:init', () => {
930
- Alpine.data('app', appState);
931
- });
932
-
933
- /* Dynamic load Alpine after listener is ready */
934
- const alpineScript = document.createElement('script');
935
- alpineScript.src = '/vendor/alpine.min.js';
936
- document.head.appendChild(alpineScript);
937
-
938
- /* ── Utilities ── */
939
- function showToast(msg) {
940
- const toast = document.getElementById(ID.TOAST);
941
- if (!toast) return;
942
- toast.textContent = msg;
943
- toast.style.display = 'block';
944
- toast.style.opacity = '1';
945
- setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => { toast.style.display = 'none'; }, 300); }, 3000);
946
- }
947
-
948
- /* ── Settings Modal ── */
949
- const SCENARIO_LABELS = { coding: '编码', testing: '测试', debugging: '调试', documentation: '文档', review: '审查', planning: '规划', refactoring: '重构' };
950
-
951
- function renderKeywordsEditor(keywords) {
952
- const container = document.getElementById('cfgKeywordsEditor');
953
- if (!container) return;
954
- container.innerHTML = '';
955
- for (const [key, label] of Object.entries(SCENARIO_LABELS)) {
956
- const words = keywords[key] || [];
957
- const row = document.createElement('div');
958
- row.className = 'kw-row';
959
- row.dataset.key = key;
960
-
961
- const lbl = document.createElement('div');
962
- lbl.className = 'kw-label';
963
- lbl.textContent = label;
964
-
965
- const tags = document.createElement('div');
966
- tags.className = 'kw-tags';
967
- for (const w of words) tags.appendChild(makeKwTag(w));
968
-
969
- const addWrap = document.createElement('div');
970
- addWrap.className = 'kw-add-row';
971
- const addBtn = document.createElement('button');
972
- addBtn.className = 'kw-add-btn';
973
- addBtn.textContent = '+';
974
- addBtn.title = '添加关键词';
975
- addBtn.onclick = () => {
976
- addWrap.innerHTML = '';
977
- const inp = document.createElement('input');
978
- inp.className = 'kw-add-input';
979
- inp.placeholder = '关键词';
980
- const ok = document.createElement('button');
981
- ok.className = 'kw-add-btn';
982
- ok.textContent = '确定';
983
- ok.onclick = () => {
984
- const v = inp.value.trim();
985
- if (v && !tags.querySelector('[data-word="' + CSS.escape(v) + '"]')) tags.insertBefore(makeKwTag(v), addWrap);
986
- resetAddBtn();
987
- };
988
- inp.onkeydown = (e) => { if (e.key === 'Enter') ok.click(); if (e.key === 'Escape') resetAddBtn(); };
989
- addWrap.appendChild(inp);
990
- addWrap.appendChild(ok);
991
- inp.focus();
992
- };
993
- function resetAddBtn() { addWrap.innerHTML = ''; addWrap.appendChild(addBtn); }
994
- resetAddBtn();
995
-
996
- row.appendChild(lbl);
997
- row.appendChild(tags);
998
- row.appendChild(addWrap);
999
- container.appendChild(row);
1000
- }
1001
- }
1002
-
1003
- function makeKwTag(word) {
1004
- const tag = document.createElement('span');
1005
- tag.className = 'kw-tag';
1006
- tag.dataset.word = word;
1007
- tag.textContent = word;
1008
- const x = document.createElement('span');
1009
- x.className = 'kw-tag-remove';
1010
- x.textContent = '×';
1011
- x.onclick = () => tag.remove();
1012
- tag.appendChild(x);
1013
- return tag;
1014
- }
1015
-
1016
- function collectKeywordsFromEditor() {
1017
- const result = {};
1018
- const container = document.getElementById('cfgKeywordsEditor');
1019
- if (!container) return result;
1020
- for (const row of container.querySelectorAll('.kw-row')) {
1021
- const key = row.dataset.key;
1022
- const words = Array.from(row.querySelectorAll('.kw-tag')).map(t => t.dataset.word);
1023
- if (words.length > 0) result[key] = words;
1024
- }
1025
- // 清洗校验:先 trim 再去重、过滤空串、截断超长词、过滤控制字符
1026
- for (const [key, words] of Object.entries(result)) {
1027
- result[key] = [...new Set(words.map(w => w.trim()))]
1028
- .filter(w => w.length > 0 && w.length <= 50)
1029
- .filter(w => !/[\x00-\x1f\x7f]/.test(w));
1030
- }
1031
- return result;
1032
- }
1033
-
1034
- window.closeSettings = () => {
1035
- const modal = document.getElementById('settingsModal');
1036
- if (modal) modal.style.display = 'none';
1037
- };
1038
-
1039
- /* ── Advanced Section Toggle ── */
1040
- window.toggleKeywordsSection = () => {
1041
- const section = document.getElementById('cfgKeywordsSection');
1042
- const btn = document.getElementById('cfgKeywordsToggle');
1043
- if (!section || !btn) return;
1044
- const isHidden = section.style.display === 'none';
1045
- section.style.display = isHidden ? 'block' : 'none';
1046
- btn.classList.toggle('expanded', isHidden);
1047
- };
1048
-
1049
- /* ── Path Tag Editor ── */
1050
- const FOLDER_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
1051
- const CLOSE_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
1052
-
1053
- function renderPathTags(containerId, paths) {
1054
- const container = document.getElementById(containerId);
1055
- if (!container) return;
1056
- container.innerHTML = '';
1057
- for (let i = 0; i < paths.length; i++) {
1058
- const tag = document.createElement('div');
1059
- tag.className = 'path-tag';
1060
- tag.innerHTML = `
1061
- <span class="path-tag-icon">${FOLDER_ICON}</span>
1062
- <span class="path-tag-text" title="${esc(paths[i])}">${esc(paths[i])}</span>
1063
- <button class="path-tag-remove" onclick="removePathTag('${containerId}', ${i})" title="删除">${CLOSE_ICON}</button>
1064
- `;
1065
- container.appendChild(tag);
1066
- }
1067
- }
1068
-
1069
- function addPathTag(containerId, inputId) {
1070
- const input = document.getElementById(inputId);
1071
- const container = document.getElementById(containerId);
1072
- if (!input || !container) return;
1073
- const raw = input.value.trim();
1074
- if (!raw) return;
1075
- // 支持粘贴多行或多逗号分隔的内容,一次性解析添加
1076
- const paths = raw.split(/[,,\n\r]+/).map(s => s.trim()).filter(Boolean);
1077
- const existing = getPathTags(containerId);
1078
- for (const p of paths) {
1079
- if (!existing.includes(p)) existing.push(p);
1080
- }
1081
- renderPathTags(containerId, existing);
1082
- input.value = '';
1083
- input.focus();
1084
- }
1085
-
1086
- function removePathTag(containerId, index) {
1087
- const paths = getPathTags(containerId);
1088
- paths.splice(index, 1);
1089
- renderPathTags(containerId, paths);
1090
- }
1091
-
1092
- function getPathTags(containerId) {
1093
- const container = document.getElementById(containerId);
1094
- if (!container) return [];
1095
- return Array.from(container.querySelectorAll('.path-tag-text')).map(el => el.textContent);
1096
- }
1097
-
1098
- window.openSettings = async () => {
1099
- const modal = document.getElementById('settingsModal');
1100
- const hint = document.getElementById('cfgSaveHint');
1101
- if (hint) { hint.textContent = ''; hint.className = ''; }
1102
- if (modal) modal.style.display = 'flex';
1103
- try {
1104
- const cfg = await fetchConfig();
1105
- const dirEl = document.getElementById('cfgClaudeDir');
1106
- if (dirEl) dirEl.value = cfg.claudeDir || '';
1107
- renderPathTags('cfgReposTags', cfg.repos || []);
1108
- renderPathTags('cfgExcludeTags', cfg.excludeProjects || []);
1109
- renderKeywordsEditor(cfg.scenarioKeywords || {});
1110
- } catch (err) {
1111
- showToast('加载配置失败: ' + err.message);
1112
- }
1113
- };
1114
-
1115
- document.getElementById('welcomeStartBtn')?.addEventListener('click', async () => {
1116
- const claudeDir = document.getElementById('welcomeClaudeDir').value.trim();
1117
- const reposRaw = document.getElementById('welcomeRepos').value.trim();
1118
- const hint = document.getElementById('welcomeHint');
1119
- if (!claudeDir) { hint.textContent = '请输入 Claude 日志目录路径'; hint.style.color = 'var(--dest)'; return; }
1120
- const repos = reposRaw ? reposRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
1121
- try {
1122
- hint.textContent = '保存配置中...'; hint.style.color = 'var(--muted-foreground)';
1123
- await saveConfig({ claudeDir, repos });
1124
- hint.textContent = '配置已保存,加载数据中...';
1125
- window.location.reload();
1126
- } catch (err) { hint.textContent = '保存失败: ' + err.message; hint.style.color = 'var(--dest)'; }
1127
- });
1128
-
1129
- window.saveSettings = async () => {
1130
- const hint = document.getElementById('cfgSaveHint');
1131
- const scenarioKeywords = collectKeywordsFromEditor();
1132
- const payload = {
1133
- claudeDir: document.getElementById('cfgClaudeDir').value.trim(),
1134
- repos: getPathTags('cfgReposTags'),
1135
- excludeProjects: getPathTags('cfgExcludeTags'),
1136
- scenarioKeywords,
1137
- };
1138
- try {
1139
- await saveConfig(payload);
1140
- if (hint) { hint.textContent = '配置已保存'; hint.className = 'cfg-save-ok'; }
1141
- setTimeout(() => window.location.reload(), 1200);
1142
- } catch (err) {
1143
- if (hint) { hint.textContent = '保存失败: ' + err.message; hint.className = 'cfg-save-err'; }
1144
- }
1145
- };
1146
-
1147
- /* ── Drill-down global handler ── */
1148
- window._drillHandler = async (type, key, label) => {
1149
- const modal = document.getElementById(ID.DRILL_MODAL);
1150
- const title = document.getElementById(ID.DRILL_TITLE);
1151
- const body = document.getElementById(ID.DRILL_BODY);
1152
- if (title) title.textContent = label + ' 匹配示例';
1153
- if (body) body.innerHTML = '<div class="drill-empty">加载中...</div>';
1154
- if (modal) modal.style.display = 'flex';
1155
- try {
1156
- const appEl = document.querySelector('[x-data]');
1157
- const app = appEl?._x_dataStack?.[0];
1158
- const period = app?.period || 'daily';
1159
- const date = app?.currentDate || new Date().toISOString().slice(0, 10);
1160
- const rows = await fetchDetails({ period, date, dimension: type, key });
1161
- if (!rows.length) { if (body) body.innerHTML = '<div class="drill-empty">无匹配记录</div>'; return; }
1162
- 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>';
1163
- } catch (e) {
1164
- console.warn('drillHandler failed:', e);
1165
- if (body) body.innerHTML = '<div class="drill-empty">加载失败</div>';
1166
- }
1167
- };
1
+ import { COLORS, SCENARIO_COLORS, TEXT, ID, STORAGE } from './config.js';
2
+ import { esc, fmt, fmtShort, destroyChart, destroyAllCharts, getChart, setChart, todayISO, fmtDate, TOOL_DISPLAY_NAMES, groupMcpByServer, aggregateToolsWithDualCounts } from './utils.js';
3
+ import { createLatestRequestGuard, fetchTools, fetchReport, fetchConfig, saveConfig, fetchDetails, fetchSessions, fetchStepStats, fetchHooksStatus, updateHooks, fetchSmartReportTools, fetchSmartReportRecord, generateSmartReport } from './api.js';
4
+ import { renderWorkTypePie, renderModelBars, renderProjectBars, renderTimelineArea, renderCacheStack } from './charts.js';
5
+ import { renderGitInsights, renderLineBlameEvidence } 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
+ reportContentMode: 'source',
40
+ smartReportTools: [],
41
+ smartReportAgent: '',
42
+ smartReportStyle: ['default', 'workhorse'].includes(localStorage.getItem(STORAGE.SMART_REPORT_STYLE)) ? localStorage.getItem(STORAGE.SMART_REPORT_STYLE) : 'default',
43
+ smartReportStyleModalOpen: false,
44
+ smartReportLoading: false,
45
+ smartReportError: '',
46
+ smartReportMarkdown: '',
47
+ smartReportHtml: '',
48
+ smartReportCopied: false,
49
+ smartReportRecord: null,
50
+ smartReportRecordMeta: '',
51
+ smartReportNeedsUpdate: false,
52
+ smartReportUpdateMessage: '',
53
+ smartReportJob: null,
54
+ smartReportStatusMessage: '',
55
+ smartReportPollTimer: null,
56
+ smartReportElapsedTimer: null,
57
+ smartReportCompletionTimer: null,
58
+ smartReportStartedAt: '',
59
+ smartReportNow: Date.now(),
60
+ smartReportProgress: 0,
61
+
62
+ /* constants */
63
+ customStart: '',
64
+ customEnd: '',
65
+ periods: [
66
+ { id: 'daily', cn: '日', en: 'DAY' },
67
+ { id: 'weekly', cn: '周', en: 'WEEK' },
68
+ { id: 'monthly', cn: '月', en: 'MONTH' },
69
+ { id: 'custom', cn: '自定义', en: 'CUSTOM' },
70
+ ],
71
+ colors: {
72
+ rust: 'var(--rust)', dest: 'var(--dest)', forest: 'var(--forest)',
73
+ ochre: 'var(--ochre)', clay: 'var(--clay)',
74
+ },
75
+ toolColors: { claude: 'var(--claude)', codex: 'var(--codex)', opencode: 'var(--opencode)' },
76
+ toolSubNames: { claude: 'ANTHROPIC', codex: 'OPENAI', opencode: 'OSS' },
77
+
78
+ /* computed getters */
79
+ get periodMeta() { return this.periods.find(p => p.id === this.period) || this.periods[0]; },
80
+ get dateDisplay() {
81
+ if (this.period === 'custom') {
82
+ if (this.customStart && this.customEnd) return `${this.customStart.replace(/-/g, '.')} — ${this.customEnd.replace(/-/g, '.')}`;
83
+ return '选择日期范围';
84
+ }
85
+ if (this.period === 'daily') return this.currentDate.replace(/-/g, '.');
86
+ if (this.period === 'weekly') {
87
+ const d = new Date(this.currentDate);
88
+ const start = new Date(d); start.setDate(d.getDate() - d.getDay() + 1);
89
+ const end = new Date(start); end.setDate(start.getDate() + 6);
90
+ return `${fmtDate(start)} — ${fmtDate(end)}`;
91
+ }
92
+ return this.currentDate.slice(0, 7).replace('-', '.');
93
+ },
94
+ get generatedAt() { return fmtDate(new Date()) + ' · ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'}) + ' UTC+8'; },
95
+ get traceId() { return 'CT-' + this.currentDate.replace(/-/g, '-'); },
96
+
97
+ /* KPI defaults */
98
+ kpiData: [
99
+ { label: '活跃天数', sub: 'ACTIVE DAYS', value: '-', unit: '/ 31', delta: '', trend: 'flat' },
100
+ { label: '覆盖项目', sub: 'PROJECTS', value: '-', unit: '个', delta: '', trend: 'flat' },
101
+ { label: '高峰天数', sub: 'PEAK DAYS', value: '-', unit: '天', delta: '', trend: 'flat' },
102
+ { label: 'Token 消耗 · 含缓存', sub: 'TOKENS · INC. CACHE', value: '-', unit: 'M', delta: '', trend: 'flat' },
103
+ { label: '估算成本', sub: 'EST. COST USD', value: '-', unit: '', delta: '', trend: 'flat' },
104
+ ],
105
+
106
+ /* AI contribution defaults */
107
+ aiLinePct: 0,
108
+ aiLinePctDisplay: 0,
109
+ _aiPctAnim: null,
110
+ aiSummaryDesc: '',
111
+ attributionPct: '0% / 100%',
112
+ confirmedPct: 0,
113
+ inferredPct: 0,
114
+ unattribPct: 0,
115
+ sourceClaudePct: 0,
116
+ sourceCodexPct: 0,
117
+ sourceOpencodePct: 0,
118
+ sourceBreakdown: [],
119
+ aiContributionMeta: '- / - LINES',
120
+ lineBlameEvidence: null,
121
+ lineBlamePrecision: '',
122
+ stepStats: null,
123
+ stepStatusLabel: '',
124
+ hooksStatus: null,
125
+ hooksBusy: false,
126
+ gitOutputCells: [
127
+ { l: '提交', en: 'COMMITS', v: '-', c: '' },
128
+ { l: '变更文件', en: 'FILES', v: '-', c: '' },
129
+ { l: '新增', en: '+ ADDED', v: '-', c: 'var(--forest)' },
130
+ { l: '删除', en: '− REMOVED', v: '-', c: 'var(--dest)' },
131
+ ],
132
+ attributionCells: [
133
+ { l: 'AI 改写', en: 'REWRITE', v: '-', c: '' },
134
+ { l: 'AI 提交', en: 'COMMITS', v: '-', c: 'var(--forest)' },
135
+ { l: '可能上限', en: 'MAX', v: '-', c: '' },
136
+ { l: '高·中置信', en: 'HI · MID', v: '-', c: 'var(--ochre)' },
137
+ { l: 'AI 新增', en: '+ AI', v: '-', c: 'var(--forest)' },
138
+ { l: 'AI 删除', en: '− AI', v: '-', c: 'var(--dest)' },
139
+ ],
140
+
141
+ /* section data defaults */
142
+ editTypeData: [],
143
+ topFilesData: [],
144
+ topFilesMeta: '+0 / −0',
145
+ workTypeData: [],
146
+ modelData: [],
147
+ topModelName: '-',
148
+ activeModels: '-',
149
+ cacheHitRate: 0,
150
+ cacheDelta: '',
151
+ cacheData: [],
152
+ cacheSavingText: '',
153
+ timelineMeta: [
154
+ { l: 'PEAK DAY', v: '-', s: '-' },
155
+ { l: 'AVG / DAY', v: '-', s: 'sessions' },
156
+ { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
157
+ { l: 'IDLE DAYS', v: '-', s: 'no activity' },
158
+ ],
159
+ toolRankData: [],
160
+ toolRankTotal: 0,
161
+ toolRankTab: 'all',
162
+ toolRankTotalCalls: 0,
163
+ toolRankAllTotal: 0,
164
+ toolRankSkillTotal: 0,
165
+ toolRankMcpTotal: 0,
166
+ projectData: [],
167
+
168
+ /* tool summary for rail */
169
+ toolTokens: { all: '-' },
170
+ toolSessions: { all: 0 },
171
+
172
+ /* report view data */
173
+ reportKpis: [
174
+ { l: 'TOKENS', v: '-', s: '估算成本 -', accent: false },
175
+ { l: 'COMMITS', v: '-', s: '- / - 行', accent: false },
176
+ { l: 'AI CONTRIBUTION', v: '-', s: '- 行可独立运行', accent: true },
177
+ { l: 'ACTIVE DAYS', v: '-', s: '连续 - 天最长', accent: false },
178
+ ],
179
+ reportSubTitle: '',
180
+ reportSummary: '',
181
+ reportHighlights: [],
182
+
183
+ get hooksNeedAction() {
184
+ if (!this.hooksStatus) return false;
185
+ return !this.hooksStatus.stepsInitialized ||
186
+ !this.hooksStatus.claude?.enabled ||
187
+ !this.hooksStatus.codex?.enabled ||
188
+ !this.hooksStatus.opencode?.enabled;
189
+ },
190
+
191
+ get hooksStatusText() {
192
+ if (!this.hooksStatus) return '正在检查 hooks 状态';
193
+ const total = this.hooksStatus.projectCount ?? this.hooksStatus.claude?.total ?? 0;
194
+ if (this.hooksStatus.targetMode === 'configured-projects') {
195
+ if (total === 0) return '未配置项目,请先在设置中添加项目路径';
196
+ const parts = [
197
+ `Claude ${this.hooksStatus.claude?.enabledCount || 0}/${total}`,
198
+ `Codex ${this.hooksStatus.codex?.enabledCount || 0}/${total}`,
199
+ `OpenCode ${this.hooksStatus.opencode?.enabledCount || 0}/${total}`,
200
+ `steps ${this.hooksStatus.stepsReadyCount || 0}/${total}`,
201
+ ];
202
+ return `设置内项目 hooks:${parts.join(' / ')}`;
203
+ }
204
+ const parts = [
205
+ `Claude ${this.hooksStatus.claude?.enabled ? '已开启' : '未开启'}`,
206
+ `Codex ${this.hooksStatus.codex?.enabled ? '已开启' : '未开启'}`,
207
+ `OpenCode ${this.hooksStatus.opencode?.enabled ? '已开启' : '未开启'}`,
208
+ `steps ${this.hooksStatus.stepsInitialized ? '已初始化' : '未初始化'}`,
209
+ ];
210
+ return parts.join(' / ');
211
+ },
212
+
213
+ /* ── init ── */
214
+ async init() {
215
+ this.loadStateFromHash();
216
+ if (this.theme === 'dark') document.documentElement.classList.add('dark');
217
+ else document.documentElement.classList.remove('dark');
218
+ this.$watch('view', (value) => {
219
+ if (value === 'ledger' && this.lastReportData) {
220
+ this.$nextTick(() => this.renderCharts(this.lastReportData));
221
+ }
222
+ });
223
+ await this.loadTools();
224
+ await this.loadSmartReportTools();
225
+ await this.loadHooksStatus();
226
+ await this.loadStepStats();
227
+ // 首次加载时先获取全量数据填充侧边栏,再按当前工具加载
228
+ if (this.activeTool !== 'all') {
229
+ try {
230
+ const allData = await fetchReport({ tool: 'all', period: this.period, date: this.currentDate });
231
+ if (allData && !allData.error) {
232
+ this.computeToolTokens(allData.usageStats, allData.toolBreakdown);
233
+ }
234
+ } catch {}
235
+ }
236
+ await this.loadCurrentView();
237
+ },
238
+
239
+ /* ── theme ── */
240
+ toggleTheme() {
241
+ this.theme = this.theme === 'dark' ? 'light' : 'dark';
242
+ localStorage.setItem(STORAGE.THEME, this.theme);
243
+ if (this.theme === 'dark') document.documentElement.classList.add('dark');
244
+ else document.documentElement.classList.remove('dark');
245
+ /* re-render charts to pick up new colors */
246
+ if (this.lastReportData && this.view === 'ledger') this.renderCharts(this.lastReportData);
247
+ },
248
+
249
+ /* ── tools ── */
250
+ async loadTools() {
251
+ try {
252
+ const data = await fetchTools();
253
+ this.availableTools = data.tools || data || [];
254
+ if (data.appName) this.appName = data.appName;
255
+ if (data.appVersion) this.appVersion = data.appVersion;
256
+ } catch (e) { console.warn('loadTools failed:', e); this.availableTools = []; }
257
+ },
258
+
259
+ async loadSmartReportTools() {
260
+ try {
261
+ const data = await fetchSmartReportTools();
262
+ this.smartReportTools = data.tools || [];
263
+ const savedAgent = localStorage.getItem(STORAGE.SMART_REPORT_AGENT);
264
+ const firstDetected = this.smartReportTools.find(t => t.detected);
265
+ const savedDetected = this.smartReportTools.find(t => t.detected && t.name === savedAgent);
266
+ this.smartReportAgent = savedDetected?.name || firstDetected?.name || '';
267
+ await this.loadSmartReportRecord();
268
+ } catch (e) {
269
+ console.warn('loadSmartReportTools failed:', e);
270
+ this.smartReportTools = [];
271
+ this.smartReportAgent = '';
272
+ }
273
+ },
274
+
275
+ async loadStepStats() {
276
+ try {
277
+ const data = await fetchStepStats();
278
+ this.stepStats = data;
279
+ this.stepStatusLabel = data?.available
280
+ ? `STEP READY · ${data.stepCount || 0}`
281
+ : 'STEP NOT READY';
282
+ } catch {
283
+ this.stepStats = null;
284
+ this.stepStatusLabel = '';
285
+ }
286
+ },
287
+
288
+ async loadHooksStatus() {
289
+ try {
290
+ this.hooksStatus = await fetchHooksStatus();
291
+ } catch (e) {
292
+ console.warn('loadHooksStatus failed:', e);
293
+ this.hooksStatus = null;
294
+ }
295
+ },
296
+
297
+ showHooksConfirmModal() {
298
+ const count = this.hooksStatus?.projectCount ?? 0;
299
+ const el = document.getElementById('hooksConfirmCount');
300
+ if (el) el.textContent = count;
301
+ const modal = document.getElementById('hooksConfirmModal');
302
+ if (modal) modal.style.display = 'flex';
303
+ },
304
+
305
+ hideHooksConfirmModal() {
306
+ const modal = document.getElementById('hooksConfirmModal');
307
+ if (modal) modal.style.display = 'none';
308
+ },
309
+
310
+ async enableHooksFromUi() {
311
+ this.hideHooksConfirmModal();
312
+ if (this.hooksBusy) return;
313
+ this.hooksBusy = true;
314
+ try {
315
+ await updateHooks('enable');
316
+ await this.loadHooksStatus();
317
+ await this.loadStepStats();
318
+ showToast('hooks 已开启');
319
+ } catch (err) {
320
+ showToast('开启 hooks 失败: ' + err.message);
321
+ } finally {
322
+ this.hooksBusy = false;
323
+ }
324
+ },
325
+
326
+ showHooksDisableConfirmModal() {
327
+ const modal = document.getElementById('hooksDisableConfirmModal');
328
+ if (modal) modal.style.display = 'flex';
329
+ },
330
+
331
+ hideHooksDisableConfirmModal() {
332
+ const modal = document.getElementById('hooksDisableConfirmModal');
333
+ if (modal) modal.style.display = 'none';
334
+ },
335
+
336
+ async disableHooksFromUi() {
337
+ this.hideHooksDisableConfirmModal();
338
+ if (this.hooksBusy) return;
339
+ this.hooksBusy = true;
340
+ try {
341
+ await updateHooks('disable');
342
+ await this.loadHooksStatus();
343
+ await this.loadStepStats();
344
+ showToast('hooks 已关闭');
345
+ } catch (err) {
346
+ showToast('关闭 hooks 失败: ' + err.message);
347
+ } finally {
348
+ this.hooksBusy = false;
349
+ }
350
+ },
351
+
352
+ setTool(name) {
353
+ this.activeTool = name;
354
+ this.resetSmartReportDisplay();
355
+ this.loadCurrentView();
356
+ if (this.view === 'report') this.loadReportContent();
357
+ },
358
+
359
+ setToolRankTab(tab) {
360
+ this.toolRankTab = tab;
361
+ this._computeToolRank();
362
+ const container = document.getElementById('toolCallsContainer');
363
+ if (container) container.scrollTop = 0;
364
+ },
365
+
366
+ _computeToolRank() {
367
+ const usageStats = this._lastUsageStats || {};
368
+ const tab = this.toolRankTab;
369
+ const getCalls = (val) => typeof val === 'number' ? val : (val.calls || 0);
370
+ const getUses = (val) => typeof val === 'number' ? val : (val.uses || 0);
371
+ const sumCalls = (obj) => Object.values(obj || {}).reduce((s, v) => s + getCalls(v), 0);
372
+
373
+ this.toolRankAllTotal = sumCalls(usageStats.tools);
374
+ this.toolRankSkillTotal = sumCalls(usageStats.skills);
375
+ this.toolRankMcpTotal = sumCalls(usageStats.mcpTools);
376
+
377
+ if (tab === 'all') {
378
+ const dual = aggregateToolsWithDualCounts(usageStats.tools || {});
379
+ const entries = Object.entries(dual).sort((a, b) => b[1].uses - a[1].uses);
380
+ const maxUses = Math.max(...entries.map(([, v]) => v.uses), 1);
381
+ this.toolRankData = entries.map(([name, d]) => ({
382
+ name,
383
+ calls: d.calls,
384
+ uses: d.uses,
385
+ value: d.calls,
386
+ pct: Math.round((d.uses / maxUses) * 100),
387
+ displayName: TOOL_DISPLAY_NAMES[name] || '',
388
+ }));
389
+ this.toolRankTotalCalls = this.toolRankAllTotal;
390
+ } else if (tab === 'skill') {
391
+ const skills = usageStats.skills || {};
392
+ const entries = Object.entries(skills).sort((a, b) => getUses(b[1]) - getUses(a[1]));
393
+ const maxUses = Math.max(...entries.map(([, v]) => getUses(v)), 1);
394
+ this.toolRankData = entries.map(([name, val]) => {
395
+ const calls = getCalls(val);
396
+ const uses = getUses(val);
397
+ return {
398
+ name,
399
+ calls,
400
+ uses,
401
+ value: calls,
402
+ pct: Math.round((uses / maxUses) * 100),
403
+ displayName: '',
404
+ };
405
+ });
406
+ this.toolRankTotalCalls = this.toolRankSkillTotal;
407
+ } else if (tab === 'mcp') {
408
+ this.toolRankData = groupMcpByServer(usageStats.mcpTools || {});
409
+ this.toolRankTotalCalls = this.toolRankMcpTotal;
410
+ }
411
+
412
+ this.toolRankTotal = this.toolRankData.length;
413
+ let rank = 0;
414
+ for (const item of this.toolRankData) {
415
+ if (!item.isGroup) item.rank = ++rank;
416
+ }
417
+ },
418
+
419
+ /* ── period / date ── */
420
+ setPeriod(p) {
421
+ this.period = p;
422
+ if (p !== 'custom') {
423
+ this.customStart = '';
424
+ this.customEnd = '';
425
+ this.saveStateToHash();
426
+ this.resetSmartReportDisplay();
427
+ this.loadCurrentView();
428
+ if (this.view === 'report') this.loadReportContent();
429
+ }
430
+ },
431
+
432
+ onCustomStartChange() {
433
+ if (this.customStart && this.customEnd) {
434
+ this.resetSmartReportDisplay();
435
+ this.loadCurrentView();
436
+ if (this.view === 'report') this.loadReportContent();
437
+ }
438
+ },
439
+
440
+ onCustomEndChange() {
441
+ if (this.customStart && this.customEnd) {
442
+ this.resetSmartReportDisplay();
443
+ this.loadCurrentView();
444
+ if (this.view === 'report') this.loadReportContent();
445
+ }
446
+ },
447
+
448
+ shiftDate(dir) {
449
+ const d = new Date(this.currentDate);
450
+ if (this.period === 'monthly') {
451
+ d.setMonth(d.getMonth() + dir);
452
+ } else {
453
+ const step = this.period === 'weekly' ? 7 * dir : dir;
454
+ d.setDate(d.getDate() + step);
455
+ }
456
+ this.currentDate = d.toISOString().slice(0, 10);
457
+ this.saveStateToHash();
458
+ this.resetSmartReportDisplay();
459
+ this.loadCurrentView();
460
+ if (this.view === 'report') this.loadReportContent();
461
+ },
462
+
463
+ onDateChange() {
464
+ if (this.currentDate > this.today) this.currentDate = this.today;
465
+ this.saveStateToHash();
466
+ this.resetSmartReportDisplay();
467
+ this.loadCurrentView();
468
+ if (this.view === 'report') this.loadReportContent();
469
+ },
470
+
471
+ loadStateFromHash() {
472
+ const hash = location.hash.slice(1);
473
+ if (!hash) return;
474
+ const [p, d] = hash.split('/');
475
+ if (p && ['daily', 'weekly', 'monthly', 'custom'].includes(p)) this.period = p;
476
+ if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) this.currentDate = d;
477
+ },
478
+
479
+ saveStateToHash() {
480
+ location.hash = `${this.period}/${this.currentDate}`;
481
+ },
482
+
483
+ /* ── data loading ── */
484
+ async loadCurrentView() {
485
+ const cacheKey = `${this.activeTool}-${this.period}-${this.period === 'custom' ? this.customStart + '~' + this.customEnd : this.currentDate}`;
486
+ const request = this.reportRequestGuard.next();
487
+
488
+ if (this.cache[cacheKey]) {
489
+ const idx = this._cacheOrder.indexOf(cacheKey);
490
+ if (idx !== -1) { this._cacheOrder.splice(idx, 1); this._cacheOrder.push(cacheKey); }
491
+ this.renderData(this.cache[cacheKey]);
492
+ this.loading = false;
493
+ return;
494
+ }
495
+
496
+ this.loading = true;
497
+ this.error = null;
498
+
499
+ try {
500
+ const params = { tool: this.activeTool, period: this.period, date: this.currentDate };
501
+ if (this.period === 'custom' && this.customStart && this.customEnd) {
502
+ params.start = this.customStart;
503
+ params.end = this.customEnd;
504
+ }
505
+ const data = await fetchReport(params, request.signal);
506
+ if (!request.isCurrent()) return;
507
+
508
+ if (!data || data.error) {
509
+ this.hasData = false;
510
+ if (data?.error === TEXT.NOT_CONFIGURED) {
511
+ this.showWelcome();
512
+ }
513
+ return;
514
+ }
515
+
516
+ this.hideWelcome();
517
+ this.cache[cacheKey] = data;
518
+ this._cacheOrder.push(cacheKey);
519
+ while (this._cacheOrder.length > this._cacheMaxSize) {
520
+ const old = this._cacheOrder.shift();
521
+ delete this.cache[old];
522
+ }
523
+ this.lastReportData = data;
524
+ this.renderData(data);
525
+ } catch (err) {
526
+ if (err.name === 'AbortError') return;
527
+ this.error = err.message;
528
+ showToast('加载失败: ' + err.message);
529
+ } finally {
530
+ if (request.isCurrent()) this.loading = false;
531
+ }
532
+ },
533
+
534
+ showWelcome() {
535
+ const wp = document.getElementById(ID.WELCOME_PAGE);
536
+ if (wp) wp.style.display = 'flex';
537
+ },
538
+
539
+ hideWelcome() {
540
+ const wp = document.getElementById(ID.WELCOME_PAGE);
541
+ if (wp) wp.style.display = 'none';
542
+ },
543
+
544
+ /* ── render data ── */
545
+ renderData(data) {
546
+ const { usageStats, gitStats, start, end, prevStats, trendData, costBreakdown } = data;
547
+ this._lastUsageStats = usageStats;
548
+ this.hasData = usageStats.requestCount > 0;
549
+ if (!this.hasData) {
550
+ this.kpiData = [
551
+ { label: '活跃天数', sub: 'ACTIVE DAYS', value: '-', unit: '/ ' + (this.period === 'daily' ? '1' : this.period === 'weekly' ? '7' : '31'), delta: '', trend: 'flat' },
552
+ { label: '覆盖项目', sub: 'PROJECTS', value: '0', unit: '个', delta: '', trend: 'flat' },
553
+ { label: '高峰天数', sub: 'PEAK DAYS', value: '-', unit: '天', delta: '', trend: 'flat' },
554
+ { label: 'Token 消耗 · 含缓存', sub: 'TOKENS · INC. CACHE', value: '0.00', unit: 'M', delta: '', trend: 'flat' },
555
+ { label: '估算成本', sub: 'EST. COST USD', value: '$0.00', unit: '', delta: '', trend: 'flat' },
556
+ ];
557
+ destroyAllCharts(['workTypeChart', 'modelChart', 'projectChart', 'toolChart', 'timelineChart', 'commitTypeChart', 'cacheChart']);
558
+ return;
559
+ }
560
+
561
+ /* KPI strip */
562
+ const days = Object.keys(usageStats.dailyStats || {}).length || 1;
563
+ const totalMin = Math.round((usageStats.requestCount || 0) * 2.4);
564
+ const peakDay = Object.entries(usageStats.dailyStats || {}).sort((a, b) => (b[1].requests || 0) - (a[1].requests || 0))[0];
565
+ const tokensM = (usageStats.totalTokens / 1_000_000).toFixed(2);
566
+ const cost = usageStats.estimatedCost || 0;
567
+ const prevCost = prevStats?.estimatedCost || 0;
568
+ const costDelta = prevCost > 0 ? ((cost - prevCost) / prevCost * 100).toFixed(1) : 0;
569
+ const costTrend = cost > prevCost ? 'up' : cost < prevCost ? 'down' : 'flat';
570
+
571
+ this.kpiData = [
572
+ { label: '活跃天数', sub: 'ACTIVE DAYS', value: String(days), unit: '/ ' + (this.period === 'daily' ? '1' : this.period === 'weekly' ? '7' : '31'), delta: '', trend: 'flat' },
573
+ { label: '覆盖项目', sub: 'PROJECTS', value: String(Object.keys(usageStats.projects || {}).length), unit: '个', delta: '', trend: 'flat' },
574
+ { label: '高峰天数', sub: 'PEAK DAYS', value: peakDay ? peakDay[0].slice(5) : '-', unit: '天', delta: '', trend: 'flat' },
575
+ { label: 'Token 消耗 · 含缓存', sub: 'TOKENS · INC. CACHE', value: tokensM, unit: 'M', delta: '', trend: 'flat' },
576
+ { label: '估算成本', sub: 'EST. COST USD', value: '$' + cost.toFixed(2), unit: '', delta: (costDelta > 0 ? '+' : '') + costDelta + '%', trend: costTrend },
577
+ ];
578
+
579
+ /* AI contribution */
580
+ this.renderAIContribution(gitStats, usageStats);
581
+
582
+ /* Edit types (commit types) */
583
+ const typeEntries = gitStats?.commitTypes ? Object.entries(gitStats.commitTypes).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]) : [];
584
+ const maxType = Math.max(...typeEntries.map(([, v]) => v), 1);
585
+ const inkSteps = ['var(--rust)', 'var(--ink-82)', 'var(--ink-62)', 'var(--ink-46)', 'var(--ink-32)', 'var(--ink-22)'];
586
+ this.editTypeData = typeEntries.map(([name, value], idx) => ({
587
+ name, value, pct: Math.round((value / maxType) * 100),
588
+ color: inkSteps[Math.min(idx, inkSteps.length - 1)],
589
+ }));
590
+
591
+ /* Top files */
592
+ const hotspots = gitStats?.fileHotspots || [];
593
+ this.topFilesData = hotspots.slice(0, 10).map(h => ({ path: h.path, commits: h.touches, plus: h.added, minus: h.deleted }));
594
+ const totalAdded = hotspots.reduce((s, h) => s + (h.added || 0), 0);
595
+ const totalDeleted = hotspots.reduce((s, h) => s + (h.deleted || 0), 0);
596
+ this.topFilesMeta = `+${fmt(totalAdded)} / −${fmt(totalDeleted)}`;
597
+
598
+ /* Work type (scenarios) */
599
+ const scenarioEntries = Object.entries(usageStats.scenarios || {}).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
600
+ const totalScenario = scenarioEntries.reduce((s, [, v]) => s + v, 0) || 1;
601
+ this.workTypeData = scenarioEntries.map(([name, value], i) => ({
602
+ name, value: Math.round((value / totalScenario) * 100),
603
+ color: SCENARIO_COLORS[name] || '#888',
604
+ hidden: false,
605
+ }));
606
+
607
+ /* Models */
608
+ const modelEntries = Object.entries(usageStats.models || {}).sort((a, b) => b[1].count - a[1].count);
609
+ const maxModel = Math.max(...modelEntries.map(([, v]) => v.count), 1);
610
+ const totalModelReq = modelEntries.reduce((s, [, v]) => s + v.count, 0) || 1;
611
+ this.modelData = modelEntries.map(([name, d]) => ({
612
+ name, pct: Math.round((d.count / totalModelReq) * 100),
613
+ barPct: Math.round((d.count / maxModel) * 100),
614
+ }));
615
+ this.topModelName = modelEntries[0]?.[0] || '-';
616
+ this.activeModels = `${modelEntries.length} / 12`;
617
+
618
+ /* Cache */
619
+ const cacheRead = usageStats.cacheRead || 0;
620
+ const cacheCreate = usageStats.cacheCreate || 0;
621
+ const inputTok = usageStats.inputTokens || 1;
622
+ const cacheTotal = cacheRead + cacheCreate + inputTok;
623
+ this.cacheHitRate = cacheTotal > 0 ? Math.round((cacheRead / cacheTotal) * 100) : 0;
624
+ this.cacheDelta = cacheRead > 0 ? '+17pp' : '';
625
+ this.cacheData = [
626
+ { label: '命中', en: 'HIT', value: this.cacheHitRate, color: 'var(--forest)' },
627
+ { label: '未命中', en: 'MISS', value: cacheTotal > 0 ? Math.round((inputTok / cacheTotal) * 100) : 0, color: 'var(--ochre)' },
628
+ { label: '未启用', en: 'OFF', value: cacheTotal > 0 ? Math.max(0, 100 - this.cacheHitRate - Math.round((inputTok / cacheTotal) * 100)) : 0, color: 'var(--clay)' },
629
+ ];
630
+ const saving = costBreakdown?.cacheSaving || 0;
631
+ this.cacheSavingText = saving > 0 ? `本月缓存命中节省 <span class="font-mono" style="color:var(--forest)">$${saving.toFixed(2)}</span> 总成本 ${((saving / Math.max(cost, 1)) * 100).toFixed(1)}%` : '';
632
+
633
+ /* Timeline */
634
+ this.renderTimeline(trendData, usageStats);
635
+
636
+ /* Projects */
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
+ this.projectData = projEntries.map(([name, d]) => ({ name: name.length > 20 ? '...' + name.slice(-17) : name, value: d.requests }));
639
+
640
+ /* Tool rank */
641
+ this._computeToolRank();
642
+
643
+ /* Tool rail tokens — only refresh sidebar when viewing all tools */
644
+ if (this.activeTool === 'all') {
645
+ this.computeToolTokens(usageStats, data.toolBreakdown);
646
+ }
647
+
648
+ /* Git insights (existing chart + table) */
649
+ if (gitStats && (gitStats.commits > 0 || gitStats.filesChanged > 0)) {
650
+ renderGitInsights(gitStats, this.activeTool);
651
+ }
652
+
653
+ /* Report view data pre-compute */
654
+ this.computeReportData(data);
655
+
656
+ /* Project list for report view */
657
+ this.reportProjects = Object.keys(data.projectDetails || {}).sort();
658
+
659
+ /* Charts (Chart.js) */
660
+ this.$nextTick(() => this.renderCharts(data));
661
+ },
662
+
663
+ toggleWorkType(idx) {
664
+ const item = this.workTypeData[idx];
665
+ if (!item) return;
666
+ item.hidden = !item.hidden;
667
+ const chart = getChart('workTypeChart');
668
+ if (chart) {
669
+ chart.toggleDataVisibility(idx);
670
+ chart.update();
671
+ }
672
+ },
673
+
674
+ _animatePct(target) {
675
+ if (this._aiPctAnim) cancelAnimationFrame(this._aiPctAnim);
676
+ const start = this.aiLinePctDisplay || 0;
677
+ const duration = 800;
678
+ const t0 = performance.now();
679
+ const tick = (now) => {
680
+ const elapsed = now - t0;
681
+ const progress = Math.min(elapsed / duration, 1);
682
+ const eased = 1 - Math.pow(1 - progress, 3);
683
+ this.aiLinePctDisplay = Math.round(start + (target - start) * eased);
684
+ if (progress < 1) this._aiPctAnim = requestAnimationFrame(tick);
685
+ };
686
+ this._aiPctAnim = requestAnimationFrame(tick);
687
+ },
688
+
689
+ renderAIContribution(gitStats, usageStats) {
690
+ const ai = gitStats?.aiContribution;
691
+ if (!ai || !gitStats || gitStats.commits <= 0) {
692
+ this.aiLinePct = 0;
693
+ this.aiLinePctDisplay = 0;
694
+ if (this._aiPctAnim) cancelAnimationFrame(this._aiPctAnim);
695
+ this.aiSummaryDesc = '暂无 Git 数据';
696
+ return;
697
+ }
698
+ const totalLines = ai.totalLinesChanged || (ai.aiFileLinesAdded + ai.aiFileLinesDeleted + (ai.humanLinesChanged || 0)) || 1;
699
+ const targetPct = Math.round((ai.aiLinesChanged / totalLines) * 100) || Math.round((ai.aiLineRatio || 0) * 100);
700
+ this.aiLinePct = targetPct;
701
+ this._animatePct(targetPct);
702
+ this.aiContributionMeta = `${fmt(ai.aiLinesChanged || 0)} / ${fmt(totalLines)} LINES`;
703
+
704
+ if (gitStats.attributionSummary) {
705
+ const s = gitStats.attributionSummary;
706
+ const upperPct = Math.round(((s.confirmedAILines + s.probableAILines + s.possibleAILines) / (s.totalLinesChanged || 1)) * 100);
707
+ const weightedPct = Math.round((ai.weightedAILineRatio || 0) * 100);
708
+ let desc = '代码变更有 AI 参与';
709
+ if (ai.possibleAICommits > 0) {
710
+ desc += `,可能 AI 影响 <strong>${ai.possibleAICommits}</strong> 提交`;
711
+ }
712
+ if (weightedPct > targetPct) {
713
+ desc += `,加权影响力 <strong>${weightedPct}%</strong>`;
714
+ }
715
+ if (s.mergeCommits > 0) {
716
+ desc += `,已排除 <strong>${s.mergeCommits}</strong> 个合并提交`;
717
+ }
718
+ this.aiSummaryDesc = desc;
719
+ this.confirmedPct = Math.round((s.confirmedAILines / (s.totalLinesChanged || 1)) * 100);
720
+ this.inferredPct = Math.round((s.probableAILines / (s.totalLinesChanged || 1)) * 100);
721
+ this.unattribPct = Math.max(0, 100 - this.confirmedPct - this.inferredPct);
722
+ this.attributionPct = `${this.confirmedPct}% / ${upperPct}%`;
723
+ } else {
724
+ this.aiSummaryDesc = '代码变更有 AI 参与';
725
+ this.confirmedPct = this.aiLinePct;
726
+ this.inferredPct = 0;
727
+ this.unattribPct = 100 - this.aiLinePct;
728
+ this.attributionPct = `${this.aiLinePct}% / 100%`;
729
+ }
730
+
731
+ const commitPct = Math.round((ai.aiCommits / gitStats.commits) * 100);
732
+ this.gitOutputCells = [
733
+ { l: '提交', en: 'COMMITS', v: String(gitStats.commits), c: '' },
734
+ { l: '变更文件', en: 'FILES', v: String(gitStats.filesChanged), c: '' },
735
+ { l: '新增', en: '+ ADDED', v: '+' + fmt(gitStats.linesAdded), c: 'var(--forest)' },
736
+ { l: '删除', en: '− REMOVED', v: '' + fmt(gitStats.linesDeleted), c: 'var(--dest)' },
737
+ ];
738
+ this.attributionCells = [
739
+ { l: 'AI 改写', en: 'REWRITE', v: this.aiLinePct + '%', c: '' },
740
+ { l: 'AI 提交', en: 'COMMITS', v: `${ai.aiCommits}/${gitStats.commits}`, c: 'var(--forest)' },
741
+ { l: '可能上限', en: 'MAX', v: (this.confirmedPct + this.inferredPct) + '%', c: '' },
742
+ { l: '高·中置信', en: 'HI · MID', v: `${ai.highConfidenceCommits}/${ai.mediumConfidenceCommits}`, c: 'var(--ochre)' },
743
+ { l: 'AI 新增', en: '+ AI', v: '+' + fmt(ai.aiFileLinesAdded), c: 'var(--forest)' },
744
+ { l: 'AI 删除', en: '− AI', v: '−' + fmt(ai.aiFileLinesDeleted), c: 'var(--dest)' },
745
+ ];
746
+
747
+ /* Source breakdown from real toolBreakdown data */
748
+ const toolTokMap = {};
749
+ const toolColors = { claude: 'var(--claude)', codex: 'var(--codex)', opencode: 'var(--opencode)' };
750
+ const toolDisplayNames = { claude: 'Claude Code', codex: 'OpenAI Codex', opencode: 'OpenCode' };
751
+ if (usageStats.toolBreakdown) {
752
+ for (const [k, v] of Object.entries(usageStats.toolBreakdown)) {
753
+ toolTokMap[k] = (v.inputTokens || 0) + (v.outputTokens || 0);
754
+ }
755
+ }
756
+ const entries = Object.entries(toolTokMap).filter(([, v]) => v > 0);
757
+ const totalToolTok = entries.reduce((s, [, v]) => s + v, 0) || 1;
758
+ const sorted = entries.sort((a, b) => b[1] - a[1]);
759
+ let pctSum = 0;
760
+ this.sourceBreakdown = sorted.map(([name, tok], i) => {
761
+ const isLast = i === sorted.length - 1;
762
+ const pct = isLast ? Math.max(0, 100 - pctSum) : Math.round((tok / totalToolTok) * 100);
763
+ pctSum += pct;
764
+ return { name: toolDisplayNames[name] || name, pct, tokens: fmtShort(tok), color: toolColors[name] || 'var(--foreground)' };
765
+ });
766
+
767
+ /* Line-level blame evidence */
768
+ const blameEv = renderLineBlameEvidence(gitStats?.commitList);
769
+ if (blameEv) {
770
+ this.lineBlameEvidence = blameEv;
771
+ this.lineBlamePrecision = `行级归因: ${blameEv.aiLines}/${blameEv.totalLines} 行 (${blameEv.precision}%) · ${blameEv.commitCount} 提交`;
772
+ } else {
773
+ this.lineBlameEvidence = null;
774
+ this.lineBlamePrecision = '';
775
+ }
776
+ },
777
+
778
+ renderTimeline(trendData, usageStats) {
779
+ const dailyStats = trendData?.dailyStats || {};
780
+ const dates = Object.keys(dailyStats).sort();
781
+ if (dates.length === 0) {
782
+ this.timelineMeta = [
783
+ { l: 'PEAK DAY', v: '-', s: '-' },
784
+ { l: 'AVG / DAY', v: '-', s: 'sessions' },
785
+ { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
786
+ { l: 'IDLE DAYS', v: '-', s: 'no activity' },
787
+ ];
788
+ return;
789
+ }
790
+ const sessionsArr = dates.map(d => dailyStats[d].requests || 0);
791
+ const tokensArr = dates.map(d => ((dailyStats[d].inputTokens || 0) + (dailyStats[d].outputTokens || 0)) / 1_000_000);
792
+ const maxSess = Math.max(...sessionsArr);
793
+ const maxIdx = sessionsArr.indexOf(maxSess);
794
+ const avgSess = (sessionsArr.reduce((s, v) => s + v, 0) / sessionsArr.length).toFixed(1);
795
+ this.timelineMeta = [
796
+ { l: 'PEAK DAY', v: dates[maxIdx]?.slice(5).replace('-', '.') || '-', s: maxSess + ' sessions' },
797
+ { l: 'AVG / DAY', v: avgSess, s: 'sessions' },
798
+ { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
799
+ { l: 'IDLE DAYS', v: '-', s: 'no activity' },
800
+ ];
801
+ },
802
+
803
+ computeToolTokens(usageStats, toolBreakdown) {
804
+ if (!toolBreakdown || Object.keys(toolBreakdown).length === 0) {
805
+ const total = usageStats.totalTokens || 0;
806
+ this.toolTokens = { all: total >= 1_000_000 ? (total / 1_000_000).toFixed(2) + 'M' : fmtShort(total) };
807
+ this.toolSessions = { all: usageStats.sessionCount || 0 };
808
+ return;
809
+ }
810
+ // 从 toolBreakdown 聚合计算 all 值,确保与各工具之和一致
811
+ let allTok = 0;
812
+ let allSess = 0;
813
+ for (const [name, data] of Object.entries(toolBreakdown)) {
814
+ const tok = (data.inputTokens || 0) + (data.outputTokens || 0) + (data.cacheRead || 0) + (data.cacheCreate || 0);
815
+ allTok += tok;
816
+ const sess = data.sessionCount || data.sessions || 0;
817
+ allSess += sess;
818
+ this.toolTokens[name] = tok >= 1_000_000 ? (tok / 1_000_000).toFixed(2) + 'M' : fmtShort(tok);
819
+ this.toolSessions[name] = sess;
820
+ }
821
+ this.toolTokens.all = allTok >= 1_000_000 ? (allTok / 1_000_000).toFixed(2) + 'M' : fmtShort(allTok);
822
+ this.toolSessions.all = allSess;
823
+ },
824
+
825
+ computeReportData(data) {
826
+ const { usageStats, gitStats, start, end, prevStats } = data;
827
+ const cost = usageStats.estimatedCost || 0;
828
+ const ai = gitStats?.aiContribution;
829
+ const aiPct = ai ? Math.round((ai.aiLinesChanged / (ai.totalLinesChanged || 1)) * 100) : 0;
830
+ const weightedPct = ai ? Math.round((ai.weightedAILineRatio || 0) * 100) : 0;
831
+ const days = Object.keys(usageStats.dailyStats || {}).length || 1;
832
+ let aiSubText = `${fmt(ai?.aiLinesChanged || 0)} 行严格可认定`;
833
+ if (ai?.possibleAICommits > 0) {
834
+ aiSubText += `,${ai.possibleAICommits} 提交可能 AI 参与`;
835
+ }
836
+ this.reportKpis = [
837
+ { l: 'TOKENS', v: (usageStats.totalTokens / 1_000_000).toFixed(2) + 'M', s: `估算成本 $${cost.toFixed(2)}`, accent: false },
838
+ { l: 'COMMITS', v: String(gitStats?.commits || 0), s: `+${fmt(gitStats?.linesAdded || 0)} / −${fmt(gitStats?.linesDeleted || 0)} 行`, accent: false },
839
+ { l: 'AI CONTRIBUTION', v: aiPct + '%', s: aiSubText, accent: true },
840
+ { l: 'ACTIVE DAYS', v: days + ' / ' + (this.period === 'weekly' ? '7' : '31'), s: '连续 - 天最长', accent: false },
841
+ ];
842
+ this.reportSubTitle = `生成 ${start}${end !== start ? ' ~ ' + end : ''} · 来源 ${this.availableTools.length + 1} 个工具`;
843
+ 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>`;
844
+ if (weightedPct > aiPct) {
845
+ summaryText += `,加权 AI 影响力 ${weightedPct}%`;
846
+ }
847
+ summaryText += '。';
848
+ this.reportSummary = summaryText;
849
+ this.reportHighlights = [
850
+ { l: 'AI 主导编辑占比', v: aiPct + '%' },
851
+ { l: '本月新增提交', v: String(gitStats?.commits || 0) },
852
+ { l: '节省推理成本', v: '$' + (data.costBreakdown?.cacheSaving || 0).toFixed(2), c: 'var(--forest)' },
853
+ { l: 'Cache 命中率提升', v: '+17pp', c: 'var(--forest)' },
854
+ { l: '活跃模型数', v: `${Object.keys(usageStats.models || {}).length} / 12` },
855
+ { l: '工作仓库数', v: String(Object.keys(usageStats.projects || {}).length) },
856
+ ];
857
+ },
858
+
859
+ renderCharts(data) {
860
+ const { usageStats, gitStats, trendData, costBreakdown } = data;
861
+ if (!usageStats || usageStats.requestCount <= 0) {
862
+ destroyAllCharts(['workTypeChart', 'modelChart', 'projectChart', 'toolChart', 'timelineChart', 'commitTypeChart']);
863
+ return;
864
+ }
865
+
866
+ /* Work Type Pie */
867
+ const scenarioEntries = Object.entries(usageStats.scenarios || {}).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
868
+ renderWorkTypePie('workTypeChart', scenarioEntries);
869
+
870
+ /* Model Bars */
871
+ const modelEntries = Object.entries(usageStats.models || {}).sort((a, b) => b[1].count - a[1].count);
872
+ renderModelBars('modelBarsContainer', modelEntries);
873
+
874
+ /* Project Bars */
875
+ const projEntries = Object.entries(usageStats.projects || {}).filter(([, d]) => d.requests > 0).sort((a, b) => b[1].requests - a[1].requests).slice(0, 8);
876
+ renderProjectBars('projectChart', projEntries);
877
+
878
+ /* Timeline Area */
879
+ if (trendData && Object.keys(trendData.dailyStats || {}).length > 0) {
880
+ renderTimelineArea('timelineChart', trendData);
881
+ } else {
882
+ destroyChart('timelineChart');
883
+ }
884
+
885
+ /* Cache is rendered via pure HTML/CSS bars in the new design */
886
+ },
887
+
888
+ /* ── view switching ── */
889
+ openReport() {
890
+ this.view = 'report';
891
+ this.loadReportContent();
892
+ },
893
+
894
+ async loadReportContent() {
895
+ try {
896
+ if (!['detailed', 'brief'].includes(this.reportLevel)) this.reportLevel = 'detailed';
897
+ const params = { tool: this.activeTool, period: this.period, date: this.currentDate, format: 'work', platform: this.reportPlatform, level: this.reportLevel };
898
+ if (this.period === 'custom' && this.customStart && this.customEnd) {
899
+ params.start = this.customStart;
900
+ params.end = this.customEnd;
901
+ }
902
+ if (this.reportProject) {
903
+ params.project = this.reportProject;
904
+ }
905
+ const qs = new URLSearchParams(params).toString();
906
+ const res = await fetch(`/api/report?${qs}`);
907
+ if (!res.ok) return;
908
+ const markdown = await res.text();
909
+ setWorkReportState({ markdown, platform: this.reportPlatform, level: this.reportLevel });
910
+ this.reportHtml = this.renderMarkdownToReportHtml(markdown);
911
+ await this.loadSmartReportRecord();
912
+ } catch (e) { console.warn('loadReportContent failed:', e); }
913
+ },
914
+
915
+ setReportLevel(level) {
916
+ this.reportLevel = ['detailed', 'brief'].includes(level) ? level : 'detailed';
917
+ this.resetSmartReportDisplay();
918
+ this.loadReportContent();
919
+ },
920
+
921
+ setReportPlatform(platform) {
922
+ this.reportPlatform = platform;
923
+ this.resetSmartReportDisplay();
924
+ this.loadReportContent();
925
+ },
926
+
927
+ setReportProject(project) {
928
+ this.reportProject = project;
929
+ this.resetSmartReportDisplay();
930
+ this.loadReportContent();
931
+ },
932
+
933
+ setSmartReportAgent(agent) {
934
+ this.smartReportAgent = agent;
935
+ localStorage.setItem(STORAGE.SMART_REPORT_AGENT, agent);
936
+ this.resetSmartReportDisplay();
937
+ this.loadSmartReportRecord();
938
+ },
939
+
940
+ resetSmartReportDisplay() {
941
+ this.stopSmartReportPolling();
942
+ this.stopSmartReportElapsedTimer();
943
+ this.stopSmartReportCompletionTimer();
944
+ this.smartReportError = '';
945
+ this.smartReportMarkdown = '';
946
+ this.smartReportHtml = '';
947
+ this.smartReportRecord = null;
948
+ this.smartReportRecordMeta = '';
949
+ this.smartReportNeedsUpdate = false;
950
+ this.smartReportUpdateMessage = '';
951
+ this.smartReportJob = null;
952
+ this.smartReportLoading = false;
953
+ this.smartReportStatusMessage = '';
954
+ this.smartReportStartedAt = '';
955
+ this.smartReportNow = Date.now();
956
+ this.smartReportProgress = 0;
957
+ this.reportContentMode = 'source';
958
+ },
959
+
960
+ smartReportParams() {
961
+ const params = {
962
+ agent: this.smartReportAgent,
963
+ tool: this.activeTool,
964
+ period: this.period,
965
+ date: this.currentDate,
966
+ level: this.reportLevel,
967
+ style: this.smartReportStyle,
968
+ platform: this.reportPlatform,
969
+ project: this.reportProject,
970
+ };
971
+ if (this.period === 'custom' && this.customStart && this.customEnd) {
972
+ params.start = this.customStart;
973
+ params.end = this.customEnd;
974
+ }
975
+ return params;
976
+ },
977
+
978
+ setReportContentMode(mode) {
979
+ this.reportContentMode = mode === 'smart' ? 'smart' : 'source';
980
+ },
981
+
982
+ openSmartReportStyleModal() {
983
+ if (!this.smartReportAgent || this.smartReportLoading) return;
984
+ this.smartReportStyleModalOpen = true;
985
+ },
986
+
987
+ closeSmartReportStyleModal() {
988
+ this.smartReportStyleModalOpen = false;
989
+ },
990
+
991
+ async confirmSmartReportStyle(style) {
992
+ this.smartReportStyle = style === 'workhorse' ? 'workhorse' : 'default';
993
+ localStorage.setItem(STORAGE.SMART_REPORT_STYLE, this.smartReportStyle);
994
+ this.closeSmartReportStyleModal();
995
+ this.resetSmartReportDisplay();
996
+ await this.generateSmartReportContent();
997
+ },
998
+
999
+ applySmartReportRecord(record, meta = {}) {
1000
+ if (record?.style) this.smartReportStyle = record.style;
1001
+ this.smartReportRecord = record || null;
1002
+ this.smartReportMarkdown = record?.markdown || '';
1003
+ this.smartReportHtml = this.renderMarkdownToReportHtml(this.smartReportMarkdown);
1004
+ this.smartReportRecordMeta = record ? this.formatSmartReportRecordMeta(record) : '';
1005
+ this.smartReportNeedsUpdate = !!record && !!meta.needsUpdate;
1006
+ this.smartReportUpdateMessage = this.smartReportNeedsUpdate ? '当前统计数据或原始报告已变化,建议重新生成智能报告。' : '';
1007
+ if (!record && this.reportContentMode === 'smart') this.reportContentMode = 'source';
1008
+ },
1009
+
1010
+ applySmartReportJob(job) {
1011
+ this.smartReportJob = job || null;
1012
+ if (!job) {
1013
+ this.smartReportLoading = false;
1014
+ this.smartReportStatusMessage = '';
1015
+ this.smartReportStartedAt = '';
1016
+ this.smartReportProgress = 0;
1017
+ this.stopSmartReportPolling();
1018
+ this.stopSmartReportElapsedTimer();
1019
+ this.stopSmartReportCompletionTimer();
1020
+ return;
1021
+ }
1022
+ if (job.status === 'running') {
1023
+ this.stopSmartReportCompletionTimer();
1024
+ this.smartReportLoading = true;
1025
+ this.smartReportError = '';
1026
+ this.smartReportStartedAt = job.startedAt || this.smartReportStartedAt || new Date().toISOString();
1027
+ if (this.smartReportProgress <= 0) this.smartReportProgress = 4;
1028
+ this.updateSmartReportProgress();
1029
+ this.startSmartReportElapsedTimer();
1030
+ this.smartReportStatusMessage = '后台生成中,页面可刷新,回来后会继续显示进度。';
1031
+ this.scheduleSmartReportPolling();
1032
+ return;
1033
+ }
1034
+ if (job.status === 'completed') {
1035
+ this.finishSmartReportProgress();
1036
+ return;
1037
+ }
1038
+ this.smartReportLoading = false;
1039
+ this.smartReportStatusMessage = '';
1040
+ this.smartReportStartedAt = '';
1041
+ this.smartReportProgress = 0;
1042
+ this.stopSmartReportPolling();
1043
+ this.stopSmartReportElapsedTimer();
1044
+ this.stopSmartReportCompletionTimer();
1045
+ if (job.status === 'failed') {
1046
+ this.smartReportError = job.error || '智能报告生成失败';
1047
+ showToast(this.smartReportError);
1048
+ }
1049
+ },
1050
+
1051
+ startSmartReportElapsedTimer() {
1052
+ this.smartReportNow = Date.now();
1053
+ if (this.smartReportElapsedTimer) return;
1054
+ this.smartReportElapsedTimer = setInterval(() => {
1055
+ this.smartReportNow = Date.now();
1056
+ this.updateSmartReportProgress();
1057
+ }, 1000);
1058
+ },
1059
+
1060
+ stopSmartReportElapsedTimer() {
1061
+ if (this.smartReportElapsedTimer) {
1062
+ clearInterval(this.smartReportElapsedTimer);
1063
+ this.smartReportElapsedTimer = null;
1064
+ }
1065
+ },
1066
+
1067
+ stopSmartReportCompletionTimer() {
1068
+ if (this.smartReportCompletionTimer) {
1069
+ clearTimeout(this.smartReportCompletionTimer);
1070
+ this.smartReportCompletionTimer = null;
1071
+ }
1072
+ },
1073
+
1074
+ updateSmartReportProgress() {
1075
+ if (!this.smartReportLoading || this.smartReportProgress >= 100) return;
1076
+ const startedAt = Date.parse(this.smartReportStartedAt || this.smartReportJob?.startedAt || '');
1077
+ if (!Number.isFinite(startedAt)) {
1078
+ this.smartReportProgress = Math.max(this.smartReportProgress, 4);
1079
+ return;
1080
+ }
1081
+ const seconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
1082
+ const eased = 1 - Math.exp(-seconds / 180);
1083
+ const target = Math.min(95, Math.round(6 + eased * 89));
1084
+ this.smartReportProgress = Math.max(this.smartReportProgress, target);
1085
+ },
1086
+
1087
+ finishSmartReportProgress() {
1088
+ this.stopSmartReportPolling();
1089
+ this.stopSmartReportElapsedTimer();
1090
+ this.stopSmartReportCompletionTimer();
1091
+ this.smartReportLoading = true;
1092
+ this.smartReportError = '';
1093
+ this.smartReportProgress = 100;
1094
+ this.smartReportStatusMessage = '生成完成,正在展示结果...';
1095
+ this.smartReportCompletionTimer = setTimeout(() => {
1096
+ this.smartReportCompletionTimer = null;
1097
+ this.smartReportLoading = false;
1098
+ this.smartReportStatusMessage = '';
1099
+ this.smartReportStartedAt = '';
1100
+ }, 1200);
1101
+ },
1102
+
1103
+ get smartReportElapsedLabel() {
1104
+ if (!this.smartReportLoading) return '';
1105
+ if (this.smartReportProgress >= 100) return '100%';
1106
+ const startedAt = Date.parse(this.smartReportStartedAt || this.smartReportJob?.startedAt || '');
1107
+ if (!Number.isFinite(startedAt)) return '正在启动后台任务';
1108
+ const seconds = Math.max(0, Math.floor((this.smartReportNow - startedAt) / 1000));
1109
+ if (seconds < 60) return `已等待 ${seconds} 秒`;
1110
+ const minutes = Math.floor(seconds / 60);
1111
+ const rest = seconds % 60;
1112
+ return `已等待 ${minutes} 分 ${String(rest).padStart(2, '0')} 秒`;
1113
+ },
1114
+
1115
+ scheduleSmartReportPolling() {
1116
+ this.stopSmartReportPolling();
1117
+ this.smartReportPollTimer = setTimeout(() => {
1118
+ this.smartReportPollTimer = null;
1119
+ this.loadSmartReportRecord();
1120
+ }, 2500);
1121
+ },
1122
+
1123
+ stopSmartReportPolling() {
1124
+ if (this.smartReportPollTimer) {
1125
+ clearTimeout(this.smartReportPollTimer);
1126
+ this.smartReportPollTimer = null;
1127
+ }
1128
+ },
1129
+
1130
+ formatSmartReportRecordMeta(record) {
1131
+ const updatedAt = record?.updatedAt ? new Date(record.updatedAt) : null;
1132
+ const time = updatedAt && !Number.isNaN(updatedAt.getTime()) ? updatedAt.toLocaleString('zh-CN', { hour12: false }) : '';
1133
+ const count = record?.generatedCount ? `第 ${record.generatedCount} 次生成` : '已生成';
1134
+ const styleLabel = record?.style === 'workhorse' ? '牛马风格' : '默认风格';
1135
+ return time ? `${styleLabel} · ${count} · ${time}` : `${styleLabel} · ${count}`;
1136
+ },
1137
+
1138
+ async loadSmartReportRecord() {
1139
+ if (!this.smartReportAgent) {
1140
+ this.resetSmartReportDisplay();
1141
+ return;
1142
+ }
1143
+ try {
1144
+ const data = await fetchSmartReportRecord(this.smartReportParams());
1145
+ this.smartReportError = '';
1146
+ this.applySmartReportRecord(data.record || null, { needsUpdate: data.needsUpdate });
1147
+ this.applySmartReportJob(data.job || null);
1148
+ if (data.job?.status === 'completed' && data.record) this.reportContentMode = 'smart';
1149
+ } catch (err) {
1150
+ console.warn('loadSmartReportRecord failed:', err);
1151
+ this.applySmartReportRecord(null);
1152
+ this.applySmartReportJob(null);
1153
+ }
1154
+ },
1155
+
1156
+ async generateSmartReportContent() {
1157
+ if (!this.smartReportAgent || this.smartReportLoading) return;
1158
+ this.smartReportLoading = true;
1159
+ this.smartReportError = '';
1160
+ this.smartReportStartedAt = new Date().toISOString();
1161
+ this.smartReportProgress = 4;
1162
+ this.smartReportStatusMessage = '正在提交后台生成任务...';
1163
+ this.startSmartReportElapsedTimer();
1164
+ try {
1165
+ const payload = this.smartReportParams();
1166
+ const data = await generateSmartReport(payload);
1167
+ this.applySmartReportRecord(data.record || (data.markdown ? { ...payload, markdown: data.markdown, generatedCount: 1, updatedAt: new Date().toISOString() } : null), { needsUpdate: false });
1168
+ this.applySmartReportJob(data.job || null);
1169
+ if (data.record && !data.job) this.reportContentMode = 'smart';
1170
+ } catch (err) {
1171
+ this.smartReportError = err.message || '智能报告生成失败';
1172
+ this.stopSmartReportElapsedTimer();
1173
+ showToast(this.smartReportError);
1174
+ } finally {
1175
+ if (this.smartReportJob?.status !== 'running') this.smartReportLoading = false;
1176
+ }
1177
+ },
1178
+
1179
+ async copySmartReport() {
1180
+ if (!this.smartReportMarkdown) return;
1181
+ await navigator.clipboard.writeText(this.smartReportMarkdown);
1182
+ this.smartReportCopied = true;
1183
+ setTimeout(() => this.smartReportCopied = false, 1400);
1184
+ },
1185
+
1186
+ downloadSmartReport() {
1187
+ if (!this.smartReportMarkdown) return;
1188
+ const blob = new Blob([this.smartReportMarkdown], { type: 'text/markdown;charset=utf-8' });
1189
+ const a = document.createElement('a');
1190
+ a.href = URL.createObjectURL(blob);
1191
+ a.download = `smart-report-${this.period}-${this.currentDate}.md`;
1192
+ a.click();
1193
+ URL.revokeObjectURL(a.href);
1194
+ },
1195
+
1196
+ async copyReport() {
1197
+ await copyWorkReport();
1198
+ this.copied = true;
1199
+ setTimeout(() => this.copied = false, 1400);
1200
+ },
1201
+
1202
+ downloadReport() {
1203
+ downloadMarkdown(this.period, this.currentDate);
1204
+ },
1205
+
1206
+ renderMarkdownToReportHtml(md) {
1207
+ const lines = md.split('\n');
1208
+ const out = [];
1209
+ let inTable = false;
1210
+ // Security: esc() MUST run first to neutralize HTML, then regex adds safe tags on escaped content
1211
+ const inline = s => esc(s).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>');
1212
+ for (let i = 0; i < lines.length; i++) {
1213
+ const line = lines[i];
1214
+ if (line.startsWith('|')) {
1215
+ if (!inTable) { inTable = true; out.push('<table class="md-table">'); }
1216
+ const cells = line.split('|').slice(1, -1).map(c => c.trim());
1217
+ if (cells.every(c => /^[-:]+$/.test(c.replace(/\|/g, '')))) continue;
1218
+ const tag = inTable && out[out.length - 1] === '<table class="md-table">' ? 'th' : 'td';
1219
+ out.push('<tr>' + cells.map(c => `<${tag}>${inline(c)}</${tag}>`).join('') + '</tr>');
1220
+ continue;
1221
+ } else if (inTable) { inTable = false; out.push('</table>'); }
1222
+ if (line.startsWith('# ')) { out.push(`<h1 class="md-h1">${inline(line.slice(2))}</h1>`); continue; }
1223
+ if (line.startsWith('## ')) { out.push(`<h2 class="md-h2">${inline(line.slice(3))}</h2>`); continue; }
1224
+ if (line.startsWith('### ')) { out.push(`<h3 class="md-h3">${inline(line.slice(4))}</h3>`); continue; }
1225
+ if (line.startsWith('- ') || line.startsWith('• ')) { out.push(`<li class="md-li">${inline(line.slice(2))}</li>`); continue; }
1226
+ if (/^[━─]+/.test(line.trim()) && line.trim().length >= 5) { out.push(`<div class="md-divider">${inline(line.trim())}</div>`); continue; }
1227
+ if (line.trim() === '') { out.push(''); continue; }
1228
+ out.push(`<p class="md-p">${inline(line)}</p>`);
1229
+ }
1230
+ if (inTable) out.push('</table>');
1231
+ let html = out.join('\n');
1232
+ html = html.replace(/(<li[^>]*>[<\s\S]*?<\/li>\n?)+/g, m => '<ul class="md-ul">\n' + m + '</ul>\n');
1233
+ return html;
1234
+ },
1235
+
1236
+ /* ── exports ── */
1237
+ exportCSV() { if (this.lastReportData) exportCSV(this.lastReportData, this.period); },
1238
+ exportJSON() { if (this.lastReportData) exportJSON(this.lastReportData, this.period); },
1239
+ exportHTML() { if (this.lastReportData) exportHTML(this.lastReportData, this.period); },
1240
+ printReport() { if (this.lastReportData) printReport(this.lastReportData, this.period); },
1241
+ };
1242
+ }
1243
+
1244
+ /* ── Register Alpine component ── */
1245
+ document.addEventListener('alpine:init', () => {
1246
+ Alpine.data('app', appState);
1247
+ });
1248
+
1249
+ /* Dynamic load Alpine after listener is ready */
1250
+ const alpineScript = document.createElement('script');
1251
+ alpineScript.src = '/vendor/alpine.min.js';
1252
+ document.head.appendChild(alpineScript);
1253
+
1254
+ /* ── Utilities ── */
1255
+ function showToast(msg) {
1256
+ const toast = document.getElementById(ID.TOAST);
1257
+ if (!toast) return;
1258
+ toast.textContent = msg;
1259
+ toast.style.display = 'block';
1260
+ toast.style.opacity = '1';
1261
+ setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => { toast.style.display = 'none'; }, 300); }, 3000);
1262
+ }
1263
+
1264
+ /* ── Settings Modal ── */
1265
+ const SCENARIO_LABELS = { coding: '编码', testing: '测试', debugging: '调试', documentation: '文档', review: '审查', planning: '规划', refactoring: '重构' };
1266
+
1267
+ function renderKeywordsEditor(keywords) {
1268
+ const container = document.getElementById('cfgKeywordsEditor');
1269
+ if (!container) return;
1270
+ container.innerHTML = '';
1271
+ for (const [key, label] of Object.entries(SCENARIO_LABELS)) {
1272
+ const words = keywords[key] || [];
1273
+ const row = document.createElement('div');
1274
+ row.className = 'kw-row';
1275
+ row.dataset.key = key;
1276
+
1277
+ const lbl = document.createElement('div');
1278
+ lbl.className = 'kw-label';
1279
+ lbl.textContent = label;
1280
+
1281
+ const tags = document.createElement('div');
1282
+ tags.className = 'kw-tags';
1283
+ for (const w of words) tags.appendChild(makeKwTag(w));
1284
+
1285
+ const addWrap = document.createElement('div');
1286
+ addWrap.className = 'kw-add-row';
1287
+ const addBtn = document.createElement('button');
1288
+ addBtn.className = 'kw-add-btn';
1289
+ addBtn.textContent = '+';
1290
+ addBtn.title = '添加关键词';
1291
+ addBtn.onclick = () => {
1292
+ addWrap.innerHTML = '';
1293
+ const inp = document.createElement('input');
1294
+ inp.className = 'kw-add-input';
1295
+ inp.placeholder = '关键词';
1296
+ const ok = document.createElement('button');
1297
+ ok.className = 'kw-add-btn';
1298
+ ok.textContent = '确定';
1299
+ ok.onclick = () => {
1300
+ const v = inp.value.trim();
1301
+ if (v && !tags.querySelector('[data-word="' + CSS.escape(v) + '"]')) tags.insertBefore(makeKwTag(v), addWrap);
1302
+ resetAddBtn();
1303
+ };
1304
+ inp.onkeydown = (e) => { if (e.key === 'Enter') ok.click(); if (e.key === 'Escape') resetAddBtn(); };
1305
+ addWrap.appendChild(inp);
1306
+ addWrap.appendChild(ok);
1307
+ inp.focus();
1308
+ };
1309
+ function resetAddBtn() { addWrap.innerHTML = ''; addWrap.appendChild(addBtn); }
1310
+ resetAddBtn();
1311
+
1312
+ row.appendChild(lbl);
1313
+ row.appendChild(tags);
1314
+ row.appendChild(addWrap);
1315
+ container.appendChild(row);
1316
+ }
1317
+ }
1318
+
1319
+ function makeKwTag(word) {
1320
+ const tag = document.createElement('span');
1321
+ tag.className = 'kw-tag';
1322
+ tag.dataset.word = word;
1323
+ tag.textContent = word;
1324
+ const x = document.createElement('span');
1325
+ x.className = 'kw-tag-remove';
1326
+ x.textContent = '×';
1327
+ x.onclick = () => tag.remove();
1328
+ tag.appendChild(x);
1329
+ return tag;
1330
+ }
1331
+
1332
+ function collectKeywordsFromEditor() {
1333
+ const result = {};
1334
+ const container = document.getElementById('cfgKeywordsEditor');
1335
+ if (!container) return result;
1336
+ for (const row of container.querySelectorAll('.kw-row')) {
1337
+ const key = row.dataset.key;
1338
+ const words = Array.from(row.querySelectorAll('.kw-tag')).map(t => t.dataset.word);
1339
+ if (words.length > 0) result[key] = words;
1340
+ }
1341
+ // 清洗校验:先 trim 再去重、过滤空串、截断超长词、过滤控制字符
1342
+ for (const [key, words] of Object.entries(result)) {
1343
+ result[key] = [...new Set(words.map(w => w.trim()))]
1344
+ .filter(w => w.length > 0 && w.length <= 50)
1345
+ .filter(w => !/[\x00-\x1f\x7f]/.test(w));
1346
+ }
1347
+ return result;
1348
+ }
1349
+
1350
+ window.closeSettings = () => {
1351
+ const modal = document.getElementById('settingsModal');
1352
+ if (modal) modal.style.display = 'none';
1353
+ };
1354
+
1355
+ /* ── Advanced Section Toggle ── */
1356
+ window.toggleKeywordsSection = () => {
1357
+ const section = document.getElementById('cfgKeywordsSection');
1358
+ const btn = document.getElementById('cfgKeywordsToggle');
1359
+ if (!section || !btn) return;
1360
+ const isHidden = section.style.display === 'none';
1361
+ section.style.display = isHidden ? 'block' : 'none';
1362
+ btn.classList.toggle('expanded', isHidden);
1363
+ };
1364
+
1365
+ /* ── Path Tag Editor ── */
1366
+ const FOLDER_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
1367
+ const CLOSE_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
1368
+
1369
+ function renderPathTags(containerId, paths) {
1370
+ const container = document.getElementById(containerId);
1371
+ if (!container) return;
1372
+ container.innerHTML = '';
1373
+ for (let i = 0; i < paths.length; i++) {
1374
+ const tag = document.createElement('div');
1375
+ tag.className = 'path-tag';
1376
+ tag.innerHTML = `
1377
+ <span class="path-tag-icon">${FOLDER_ICON}</span>
1378
+ <span class="path-tag-text" title="${esc(paths[i])}">${esc(paths[i])}</span>
1379
+ <button class="path-tag-remove" onclick="removePathTag('${containerId}', ${i})" title="删除">${CLOSE_ICON}</button>
1380
+ `;
1381
+ container.appendChild(tag);
1382
+ }
1383
+ }
1384
+
1385
+ function addPathTag(containerId, inputId) {
1386
+ const input = document.getElementById(inputId);
1387
+ const container = document.getElementById(containerId);
1388
+ if (!input || !container) return;
1389
+ const raw = input.value.trim();
1390
+ if (!raw) return;
1391
+ // 支持粘贴多行或多逗号分隔的内容,一次性解析添加
1392
+ const paths = raw.split(/[,,\n\r]+/).map(s => s.trim()).filter(Boolean);
1393
+ const existing = getPathTags(containerId);
1394
+ for (const p of paths) {
1395
+ if (!existing.includes(p)) existing.push(p);
1396
+ }
1397
+ renderPathTags(containerId, existing);
1398
+ input.value = '';
1399
+ input.focus();
1400
+ }
1401
+
1402
+ function removePathTag(containerId, index) {
1403
+ const paths = getPathTags(containerId);
1404
+ paths.splice(index, 1);
1405
+ renderPathTags(containerId, paths);
1406
+ }
1407
+
1408
+ function getPathTags(containerId) {
1409
+ const container = document.getElementById(containerId);
1410
+ if (!container) return [];
1411
+ return Array.from(container.querySelectorAll('.path-tag-text')).map(el => el.textContent);
1412
+ }
1413
+
1414
+ window.openSettings = async () => {
1415
+ const modal = document.getElementById('settingsModal');
1416
+ const hint = document.getElementById('cfgSaveHint');
1417
+ if (hint) { hint.textContent = ''; hint.className = ''; }
1418
+ if (modal) modal.style.display = 'flex';
1419
+ try {
1420
+ const cfg = await fetchConfig();
1421
+ const dirEl = document.getElementById('cfgClaudeDir');
1422
+ if (dirEl) dirEl.value = cfg.claudeDir || '';
1423
+ renderPathTags('cfgReposTags', cfg.repos || []);
1424
+ renderPathTags('cfgExcludeTags', cfg.excludeProjects || []);
1425
+ renderKeywordsEditor(cfg.scenarioKeywords || {});
1426
+ } catch (err) {
1427
+ showToast('加载配置失败: ' + err.message);
1428
+ }
1429
+ };
1430
+
1431
+ document.getElementById('welcomeStartBtn')?.addEventListener('click', async () => {
1432
+ const claudeDir = document.getElementById('welcomeClaudeDir').value.trim();
1433
+ const reposRaw = document.getElementById('welcomeRepos').value.trim();
1434
+ const hint = document.getElementById('welcomeHint');
1435
+ if (!claudeDir) { hint.textContent = '请输入 Claude 日志目录路径'; hint.style.color = 'var(--dest)'; return; }
1436
+ const repos = reposRaw ? reposRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
1437
+ try {
1438
+ hint.textContent = '保存配置中...'; hint.style.color = 'var(--muted-foreground)';
1439
+ await saveConfig({ claudeDir, repos });
1440
+ hint.textContent = '配置已保存,加载数据中...';
1441
+ window.location.reload();
1442
+ } catch (err) { hint.textContent = '保存失败: ' + err.message; hint.style.color = 'var(--dest)'; }
1443
+ });
1444
+
1445
+ window.saveSettings = async () => {
1446
+ const hint = document.getElementById('cfgSaveHint');
1447
+ const scenarioKeywords = collectKeywordsFromEditor();
1448
+ const payload = {
1449
+ claudeDir: document.getElementById('cfgClaudeDir').value.trim(),
1450
+ repos: getPathTags('cfgReposTags'),
1451
+ excludeProjects: getPathTags('cfgExcludeTags'),
1452
+ scenarioKeywords,
1453
+ };
1454
+ try {
1455
+ await saveConfig(payload);
1456
+ if (hint) { hint.textContent = '配置已保存'; hint.className = 'cfg-save-ok'; }
1457
+ setTimeout(() => window.location.reload(), 1200);
1458
+ } catch (err) {
1459
+ if (hint) { hint.textContent = '保存失败: ' + err.message; hint.className = 'cfg-save-err'; }
1460
+ }
1461
+ };
1462
+
1463
+ /* ── Drill-down global handler ── */
1464
+ window._drillHandler = async (type, key, label) => {
1465
+ const modal = document.getElementById(ID.DRILL_MODAL);
1466
+ const title = document.getElementById(ID.DRILL_TITLE);
1467
+ const body = document.getElementById(ID.DRILL_BODY);
1468
+ if (title) title.textContent = label + ' 匹配示例';
1469
+ if (body) body.innerHTML = '<div class="drill-empty">加载中...</div>';
1470
+ if (modal) modal.style.display = 'flex';
1471
+ try {
1472
+ const appEl = document.querySelector('[x-data]');
1473
+ const app = appEl?._x_dataStack?.[0];
1474
+ const period = app?.period || 'daily';
1475
+ const date = app?.currentDate || new Date().toISOString().slice(0, 10);
1476
+ const rows = await fetchDetails({ period, date, dimension: type, key });
1477
+ if (!rows.length) { if (body) body.innerHTML = '<div class="drill-empty">无匹配记录</div>'; return; }
1478
+ 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>';
1479
+ } catch (e) {
1480
+ console.warn('drillHandler failed:', e);
1481
+ if (body) body.innerHTML = '<div class="drill-empty">加载失败</div>';
1482
+ }
1483
+ };