lumencode 0.4.4 → 1.1.0

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