lumencode 0.4.3

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 ADDED
@@ -0,0 +1,647 @@
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, renderCacheEfficiency, 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
+ // Cache efficiency chart
251
+ const cacheSection = document.getElementById('cacheSection');
252
+ if (cacheSection) {
253
+ if (usageStats.cacheRead > 0 || usageStats.cacheCreate > 0) {
254
+ cacheSection.style.display = 'block';
255
+ renderCacheEfficiency(ID.CACHE_CHART, usageStats.cacheRead, usageStats.cacheCreate, usageStats.inputTokens, data.costBreakdown);
256
+ } else {
257
+ cacheSection.style.display = 'none';
258
+ }
259
+ }
260
+
261
+ // Model cost chart
262
+ const modelCostSection = document.getElementById('modelCostSection');
263
+ if (modelCostSection) {
264
+ if (data.costBreakdown?.models?.some(m => m.cost > 0)) {
265
+ modelCostSection.style.display = 'block';
266
+ renderModelCostChart(ID.MODEL_COST_CHART, usageStats.models, data.costBreakdown);
267
+ } else {
268
+ modelCostSection.style.display = 'none';
269
+ }
270
+ }
271
+
272
+ if (!hasData) {
273
+ destroyAllCharts([ID.SCENARIO_CHART, ID.MODEL_CHART, ID.PROJECT_CHART, ID.TOOL_CHART, ID.CACHE_CHART, ID.MODEL_COST_CHART]);
274
+ this.updateGitPanel(gitStats, this.activeTool, reposConfigured);
275
+ return;
276
+ }
277
+
278
+ // Scenarios
279
+ renderDoughnut(ID.SCENARIO_CHART, usageStats.scenarios, '场景分布');
280
+
281
+ // Models (with drill-down)
282
+ const modelEntries = Object.entries(usageStats.models).sort((a, b) => b[1].count - a[1].count);
283
+ destroyChart(ID.MODEL_CHART);
284
+ const modelCtx = document.getElementById(ID.MODEL_CHART).getContext('2d');
285
+ const modelChart = new Chart(modelCtx, {
286
+ type: 'bar',
287
+ data: { labels: modelEntries.map(([k]) => k), datasets: [{ label: '请求次数', data: modelEntries.map(([, v]) => v.count), backgroundColor: '#374151', borderRadius: 6, maxBarThickness: 20, barPercentage: 0.7 }] },
288
+ 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 } },
289
+ onClick: async (evt, elements) => {
290
+ if (elements.length === 0) return;
291
+ const model = modelEntries[elements[0].index][0];
292
+ showDrill(esc(model), '<div class="drill-empty">加载中...</div>');
293
+ try {
294
+ const rows = await fetchDetails({ period: this.activePeriod, date: this.currentDate, dimension: 'model', key: model });
295
+ if (!rows.length) { showDrill(esc(model), '<div class="drill-empty">无数据</div>'); return; }
296
+ 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>');
297
+ } catch {}
298
+ }
299
+ }
300
+ });
301
+ setChart(ID.MODEL_CHART, modelChart);
302
+
303
+ // Projects (with drill-down)
304
+ const projEntries = Object.entries(usageStats.projects).filter(([, d]) => d.requests > 0).sort((a, b) => b[1].requests - a[1].requests).slice(0, 8);
305
+ destroyChart(ID.PROJECT_CHART);
306
+ const projCtx = document.getElementById(ID.PROJECT_CHART).getContext('2d');
307
+ const projChart = new Chart(projCtx, {
308
+ type: 'bar',
309
+ 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 }] },
310
+ 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 } },
311
+ onClick: async (evt, elements) => {
312
+ if (elements.length === 0) return;
313
+ const project = projEntries[elements[0].index][0];
314
+ showDrill(esc(project), '<div class="drill-empty">加载中...</div>');
315
+ try {
316
+ const params = { project, period: this.activePeriod, date: this.currentDate };
317
+ if (this.activeTool !== 'all') params.tool = this.activeTool;
318
+ const rows = await fetchSessions(params);
319
+ if (!rows.length) { showDrill(esc(project), '<div class="drill-empty">无数据</div>'); return; }
320
+ const html = '<table class="drill-table">'
321
+ + '<tr><th></th><th>会话ID</th><th>开始</th><th>时长</th><th>请求</th><th>工具</th><th>文件</th><th>提交</th></tr>'
322
+ + rows.map((r, i) => {
323
+ const start = r.startTime ? r.startTime.slice(0, 16).replace('T', ' ') : '-';
324
+ 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') : '-';
325
+ const cn = r.commits?.length || 0;
326
+ const toggle = cn > 0 ? `<button class="commit-toggle" data-idx="${i}">▸</button>` : '';
327
+ const tools = [...new Set(r.toolSequence || [])].slice(0, 3).join(', ');
328
+ const fileCount = r.touchedFileCount || 0;
329
+ const commitRows = cn > 0
330
+ ? `<tr class="commit-subrow" data-idx="${i}" style="display:none;"><td colspan="8"><table class="commit-subtable">
331
+ <tr><th>hash</th><th>type</th><th>subject</th><th class="num">+行</th><th class="num">-行</th><th>AI</th><th>证据</th></tr>
332
+ ${r.commits.map(c => `<tr>
333
+ <td class="hash"><code>${c.hash.slice(0,7)}</code></td>
334
+ <td><span class="commit-type-tag type-${c.type}">${c.type}</span></td>
335
+ <td class="commit-subject" title="${esc(c.subject)}">${esc(c.subject)}</td>
336
+ <td class="num pos">+${fmt(c.linesAdded || 0)}</td>
337
+ <td class="num neg">-${fmt(c.linesDeleted || 0)}</td>
338
+ <td>${c.aiConfidence === 'high' ? 'H' : c.aiConfidence === 'medium' ? 'M' : c.aiConfidence === 'low' ? 'L' : ''}</td>
339
+ <td>${c.aiEvidenceDetails?.matchedFileCount ? `文件交集 ${c.aiEvidenceDetails.matchedFileCount}` : (c.attributionType || '')}</td>
340
+ </tr>`).join('')}
341
+ </table></td></tr>`
342
+ : '';
343
+ 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}`;
344
+ }).join('')
345
+ + '</table>';
346
+ showDrill(esc(project) + ' 会话记录', html);
347
+ document.querySelectorAll('.commit-toggle').forEach(btn => {
348
+ btn.addEventListener('click', () => {
349
+ const idx = btn.dataset.idx;
350
+ const sub = document.querySelector(`.commit-subrow[data-idx="${idx}"]`);
351
+ const open = sub.style.display !== 'none';
352
+ sub.style.display = open ? 'none' : '';
353
+ btn.textContent = open ? '▸' : '▾';
354
+ });
355
+ });
356
+ } catch {}
357
+ }
358
+ }
359
+ });
360
+ setChart(ID.PROJECT_CHART, projChart);
361
+
362
+ // Tools
363
+ const toolEntries = Object.entries(usageStats.tools).sort((a, b) => b[1] - a[1]).slice(0, 10);
364
+ renderBar(ID.TOOL_CHART, toolEntries.map(([k]) => k), toolEntries.map(([, v]) => v), '调用次数');
365
+
366
+ this.updateGitPanel(gitStats, this.activeTool, reposConfigured);
367
+ },
368
+
369
+ updateGitPanel(gitStats, activeTool = 'all', reposConfigured = false) {
370
+ const gitSection = document.getElementById(ID.GIT_SECTION);
371
+ const gitInsightsRow = document.getElementById(ID.GIT_INSIGHTS_ROW);
372
+ const gitConfigured = gitStats !== null || reposConfigured;
373
+ const hasGit = gitStats && (gitStats.commits > 0 || gitStats.filesChanged > 0);
374
+ if (hasGit) {
375
+ gitSection.style.display = 'block';
376
+ gitSection.dataset.hasGit = 'true';
377
+ document.getElementById(ID.GIT_STATS).innerHTML = `
378
+ <div class="git-stat-item"><div class="git-stat-value">${fmt(gitStats.commits)}</div><div class="git-stat-label">提交次数</div></div>
379
+ <div class="git-stat-item"><div class="git-stat-value">+${fmt(gitStats.linesAdded)}</div><div class="git-stat-label">新增行数</div></div>
380
+ <div class="git-stat-item"><div class="git-stat-value">-${fmt(gitStats.linesDeleted)}</div><div class="git-stat-label">删除行数</div></div>
381
+ <div class="git-stat-item"><div class="git-stat-value">${fmt(gitStats.filesChanged)}</div><div class="git-stat-label">变更文件</div></div>
382
+ `;
383
+ renderGitInsights(gitStats, activeTool);
384
+ } else {
385
+ gitSection.style.display = 'block';
386
+ gitSection.dataset.hasGit = 'false';
387
+ if (gitConfigured) {
388
+ 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>`;
389
+ } else {
390
+ 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>`;
391
+ }
392
+ document.getElementById(ID.GIT_AI_STATS).innerHTML = '';
393
+ if (gitInsightsRow) gitInsightsRow.style.display = 'none';
394
+ destroyChart('commitTypeChart');
395
+ }
396
+ },
397
+
398
+ setPeriod(period) {
399
+ this.activePeriod = period;
400
+ currentPeriod = period;
401
+ this.saveStateToHash();
402
+ resetToReportView();
403
+ this.loadCurrentView();
404
+ },
405
+
406
+ setDate(date) {
407
+ const today = new Date().toISOString().slice(0, 10);
408
+ if (date > today) date = today;
409
+ this.currentDate = date;
410
+ currentDate = date;
411
+ document.getElementById(ID.DATE_INPUT).value = date;
412
+ this.saveStateToHash();
413
+ resetToReportView();
414
+ this.loadCurrentView();
415
+ },
416
+
417
+ loadStateFromHash() {
418
+ const hash = location.hash.slice(1);
419
+ if (!hash) return;
420
+ const [p, d] = hash.split('/');
421
+ if (p && ['daily', 'weekly', 'monthly'].includes(p)) {
422
+ this.activePeriod = p;
423
+ currentPeriod = p;
424
+ document.querySelectorAll('.category-tab').forEach(b => b.classList.toggle('active', b.dataset.period === p));
425
+ }
426
+ if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) {
427
+ this.currentDate = d;
428
+ currentDate = d;
429
+ }
430
+ },
431
+
432
+ saveStateToHash() {
433
+ location.hash = `${this.activePeriod}/${this.currentDate}`;
434
+ },
435
+ }));
436
+ }
437
+
438
+ // ── Alpine 加载 ──
439
+ // Alpine 通过 queueMicrotask(start) 自动初始化
440
+ // 我们需要在 Alpine.start() 之前注册 Alpine.data()
441
+ // 方案:在 Alpine 脚本标签之前设置 alpine:init 监听器
442
+ // 用 inline script 在 head 中捕获 alpine:init 事件
443
+ document.addEventListener('alpine:init', registerAlpineComponents);
444
+ // 动态加载 Alpine(不在 HTML 预加载,确保我们的监听器先就位)
445
+ const alpineScript = document.createElement('script');
446
+ alpineScript.src = '/vendor/alpine.min.js';
447
+ document.head.appendChild(alpineScript);
448
+
449
+ // ── URL Hash State (non-Alpine fallback) ──
450
+ (function loadStateFromHashFallback() {
451
+ const hash = location.hash.slice(1);
452
+ if (!hash) return;
453
+ const [p, d] = hash.split('/');
454
+ if (p && ['daily', 'weekly', 'monthly'].includes(p)) {
455
+ currentPeriod = p;
456
+ document.querySelectorAll('.category-tab').forEach(b => b.classList.toggle('active', b.dataset.period === p));
457
+ }
458
+ if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) {
459
+ currentDate = d;
460
+ document.getElementById(ID.DATE_INPUT).value = d;
461
+ }
462
+ })();
463
+
464
+ // ── Welcome page config ──
465
+ document.getElementById(ID.WELCOME_START_BTN)?.addEventListener('click', async () => {
466
+ const claudeDir = document.getElementById(ID.WELCOME_CLAUDE_DIR).value.trim();
467
+ const reposRaw = document.getElementById(ID.WELCOME_REPOS).value.trim();
468
+ const hint = document.getElementById(ID.WELCOME_HINT);
469
+
470
+ if (!claudeDir) { hint.textContent = '请输入 Claude 日志目录路径'; hint.style.color = '#dc2626'; return; }
471
+
472
+ const repos = reposRaw ? reposRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
473
+ try {
474
+ hint.textContent = '保存配置中...';
475
+ hint.style.color = 'var(--muted)';
476
+ await saveConfig({ claudeDir, repos });
477
+ hint.textContent = '配置已保存,加载数据中...';
478
+ hideEmpty();
479
+ await loadData();
480
+ } catch (err) {
481
+ hint.textContent = '保存失败: ' + err.message;
482
+ hint.style.color = '#dc2626';
483
+ }
484
+ });
485
+
486
+ // ── Config: localStorage + server sync ──
487
+ function loadLocalConfig() {
488
+ try { return JSON.parse(localStorage.getItem(STORAGE.CONFIG) || '{}'); } catch { return {}; }
489
+ }
490
+
491
+ function saveLocalConfig(cfg) {
492
+ localStorage.setItem(STORAGE.CONFIG, JSON.stringify(cfg));
493
+ }
494
+
495
+ async function syncConfigFromServer() {
496
+ try {
497
+ const serverCfg = await fetchConfig();
498
+ const localCfg = loadLocalConfig();
499
+ saveLocalConfig({ ...localCfg, ...serverCfg });
500
+ } catch {}
501
+ }
502
+
503
+ syncConfigFromServer().then(() => {
504
+ const appEl = document.querySelector('[x-data="app()"]');
505
+ if (appEl && appEl._x_dataStack) loadData();
506
+ });
507
+
508
+ // ── Settings modal ──
509
+ const settingsModal = document.getElementById(ID.SETTINGS_MODAL);
510
+ const settingsBtn = document.getElementById(ID.SETTINGS_BTN);
511
+ const closeSettings = document.getElementById(ID.CLOSE_SETTINGS);
512
+ const saveSettingsEl = document.getElementById(ID.SAVE_SETTINGS);
513
+ const backdrop = settingsModal?.querySelector('.modal-backdrop');
514
+
515
+ settingsBtn?.addEventListener('click', async () => {
516
+ let cfg = {};
517
+ try { cfg = await fetchConfig(); } catch {}
518
+ if (Object.keys(cfg).length === 0) cfg = loadLocalConfig();
519
+ document.getElementById(ID.CFG_CLAUDE_DIR).value = cfg.claudeDir || '';
520
+ document.getElementById(ID.CFG_REPOS).value = (cfg.repos || []).join('\n');
521
+ document.getElementById(ID.CFG_EXCLUDE).value = (cfg.excludeProjects || []).join('\n');
522
+ document.getElementById(ID.CFG_KEYWORDS).value = JSON.stringify(cfg.scenarioKeywords || {}, null, 2);
523
+ settingsModal.style.display = 'flex';
524
+ });
525
+
526
+ function hideSettings() { if (settingsModal) settingsModal.style.display = 'none'; }
527
+ closeSettings?.addEventListener('click', hideSettings);
528
+ backdrop?.addEventListener('click', hideSettings);
529
+
530
+ saveSettingsEl?.addEventListener('click', async () => {
531
+ let scenarioKeywords;
532
+ try { scenarioKeywords = JSON.parse(document.getElementById(ID.CFG_KEYWORDS).value); } catch { alert('场景关键词 JSON 格式错误,请检查'); return; }
533
+ const payload = {
534
+ claudeDir: document.getElementById(ID.CFG_CLAUDE_DIR).value.trim(),
535
+ repos: document.getElementById(ID.CFG_REPOS).value.split('\n').map(s => s.trim()).filter(Boolean),
536
+ excludeProjects: document.getElementById(ID.CFG_EXCLUDE).value.split('\n').map(s => s.trim()).filter(Boolean),
537
+ scenarioKeywords,
538
+ };
539
+ saveLocalConfig(payload);
540
+ try {
541
+ await saveConfig(payload);
542
+ const appEl = document.querySelector('[x-data="app()"]');
543
+ if (appEl && appEl._x_dataStack) {
544
+ const app = appEl._x_dataStack[0];
545
+ if (app && app.cache) app.cache = {};
546
+ }
547
+ await loadData();
548
+ hideSettings();
549
+ } catch (err) {
550
+ alert('保存失败: ' + err.message);
551
+ }
552
+ });
553
+
554
+ // ── Work report events ──
555
+ document.getElementById(ID.WORK_REPORT_BTN)?.addEventListener('click', async () => {
556
+ await loadWorkReport(fetch, currentTool, currentPeriod, currentDate);
557
+ });
558
+
559
+ document.querySelectorAll('.level-tab').forEach(btn => {
560
+ btn.addEventListener('click', () => {
561
+ document.querySelectorAll('.level-tab').forEach(b => b.classList.remove('active'));
562
+ btn.classList.add('active');
563
+ loadWorkReport(fetch, currentTool, currentPeriod, currentDate, null, btn.dataset.level);
564
+ });
565
+ });
566
+
567
+ document.querySelectorAll('.platform-tab').forEach(btn => {
568
+ btn.addEventListener('click', () => {
569
+ document.querySelectorAll('.platform-tab').forEach(b => b.classList.remove('active'));
570
+ btn.classList.add('active');
571
+ loadWorkReport(fetch, currentTool, currentPeriod, currentDate, btn.dataset.platform, null);
572
+ });
573
+ });
574
+
575
+ document.getElementById(ID.BACK_TO_REPORT)?.addEventListener('click', () => {
576
+ resetToReportView();
577
+ });
578
+
579
+ document.getElementById(ID.COPY_WORK_REPORT)?.addEventListener('click', copyWorkReport);
580
+
581
+ // ── Drill-down modal close ──
582
+ document.getElementById(ID.CLOSE_DRILL)?.addEventListener('click', () => {
583
+ const drillModal = document.getElementById(ID.DRILL_MODAL);
584
+ if (drillModal) drillModal.style.display = 'none';
585
+ });
586
+ document.getElementById(ID.DRILL_MODAL)?.querySelector('.modal-backdrop')?.addEventListener('click', () => {
587
+ const drillModal = document.getElementById(ID.DRILL_MODAL);
588
+ if (drillModal) drillModal.style.display = 'none';
589
+ });
590
+
591
+ // ── Export events ──
592
+ document.getElementById(ID.EXPORT_CSV_BTN)?.addEventListener('click', () => exportCSV(lastReportData, currentPeriod));
593
+ document.getElementById(ID.PRINT_BTN)?.addEventListener('click', () => printReport(lastReportData, currentPeriod));
594
+ document.getElementById(ID.EXPORT_JSON_BTN)?.addEventListener('click', () => exportJSON(lastReportData, currentPeriod));
595
+ document.getElementById(ID.EXPORT_HTML_BTN)?.addEventListener('click', () => exportHTML(lastReportData, currentPeriod));
596
+ document.getElementById(ID.DOWNLOAD_MD_BTN)?.addEventListener('click', () => {
597
+ const state = getWorkReportState();
598
+ downloadMarkdown(currentPeriod, currentDate);
599
+ });
600
+
601
+ // ── Dark mode ──
602
+ const themeBtn = document.getElementById(ID.THEME_BTN);
603
+ const moonIcon = document.getElementById(ID.MOON_ICON);
604
+ const sunIcon = document.getElementById(ID.SUN_ICON);
605
+ const savedTheme = localStorage.getItem(STORAGE.THEME);
606
+
607
+ function updateThemeIcon() {
608
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
609
+ if (moonIcon) moonIcon.style.display = isDark ? 'none' : '';
610
+ if (sunIcon) sunIcon.style.display = isDark ? '' : 'none';
611
+ if (themeBtn) themeBtn.title = isDark ? '切换日间模式' : '切换暗色模式';
612
+ }
613
+
614
+ if (savedTheme === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
615
+ updateThemeIcon();
616
+
617
+ themeBtn?.addEventListener('click', () => {
618
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
619
+ if (isDark) {
620
+ document.documentElement.removeAttribute('data-theme');
621
+ localStorage.setItem(STORAGE.THEME, 'light');
622
+ } else {
623
+ document.documentElement.setAttribute('data-theme', 'dark');
624
+ localStorage.setItem(STORAGE.THEME, 'dark');
625
+ }
626
+ updateThemeIcon();
627
+ const workReportSection = document.getElementById(ID.WORK_REPORT_SECTION);
628
+ if (workReportSection && workReportSection.style.display !== 'none') return;
629
+ const appEl = document.querySelector('[x-data="app()"]');
630
+ if (appEl && appEl._x_dataStack) {
631
+ const app = appEl._x_dataStack[0];
632
+ if (app && app.loadCurrentView) app.loadCurrentView();
633
+ }
634
+ });
635
+
636
+ // ── Compatibility: loadData ──
637
+ async function loadData() {
638
+ const appEl = document.querySelector('[x-data="app()"]');
639
+ if (appEl && appEl._x_dataStack) {
640
+ const app = appEl._x_dataStack[0];
641
+ if (app && app.loadCurrentView) {
642
+ await app.loadCurrentView();
643
+ return { success: true };
644
+ }
645
+ }
646
+ return { success: false, error: 'alpine-not-ready' };
647
+ }