lumencode 0.4.3 → 1.0.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,647 +1,809 @@
1
- import { COLORS, TEXT, ID, STORAGE } from './config.js';
2
- import { esc, fmt, fmtShort, renderTrendArrow, destroyChart, destroyAllCharts, setChart } from './utils.js';
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
3
  import { createLatestRequestGuard, fetchTools, fetchReport, fetchConfig, saveConfig, fetchDetails, fetchSessions } from './api.js';
4
- import { renderDoughnut, renderBar, renderTrend, renderCommitTypeChart, renderCacheEfficiency, renderModelCostChart } from './charts.js';
4
+ import { renderWorkTypePie, renderModelBars, renderProjectBars, renderTimelineArea, renderCacheStack } from './charts.js';
5
5
  import { renderGitInsights } from './git-insights.js';
6
6
  import { loadWorkReport, copyWorkReport, downloadMarkdown, getWorkReportState, setWorkReportState } from './work-report.js';
7
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
8
 
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', () => ({
9
+ /* ── Alpine App Component ── */
10
+ function appState() {
11
+ return {
12
+ /* state */
13
+ view: 'ledger',
14
+ period: 'daily',
56
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,
57
23
  availableTools: [],
58
- showAddTool: false,
59
- collapsed: localStorage.getItem(STORAGE.SIDEBAR_COLLAPSED) === 'true',
24
+ appName: 'LumenCode',
25
+ appVersion: '',
26
+ lastReportData: null,
27
+ cache: {},
28
+ _cacheOrder: [],
29
+ _cacheMaxSize: 30,
30
+ reportRequestGuard: createLatestRequestGuard(),
60
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 ── */
61
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
+ });
62
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();
63
170
  },
64
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 ── */
65
183
  async loadTools() {
66
184
  try {
67
- this.availableTools = await fetchTools();
68
- } catch {
69
- this.availableTools = [];
70
- }
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 = []; }
71
190
  },
72
191
 
73
192
  setTool(name) {
74
193
  this.activeTool = name;
75
- window.dispatchEvent(new CustomEvent('tool-changed', { detail: name }));
194
+ this.loadCurrentView();
195
+ if (this.view === 'report') this.loadReportContent();
76
196
  },
77
197
 
78
- toggleCollapse() {
79
- this.collapsed = !this.collapsed;
80
- localStorage.setItem(STORAGE.SIDEBAR_COLLAPSED, String(this.collapsed));
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
+ }
81
208
  },
82
- }));
83
209
 
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();
210
+ onCustomStartChange() {
211
+ if (this.customStart && this.customEnd) {
212
+ this.loadCurrentView();
213
+ if (this.view === 'report') this.loadReportContent();
214
+ }
215
+ },
96
216
 
97
- window.addEventListener('tool-changed', e => {
98
- this.activeTool = e.detail;
99
- currentTool = e.detail;
100
- resetToReportView();
217
+ onCustomEndChange() {
218
+ if (this.customStart && this.customEnd) {
101
219
  this.loadCurrentView();
102
- });
220
+ if (this.view === 'report') this.loadReportContent();
221
+ }
222
+ },
103
223
 
104
- this.bindPeriodButtons();
105
- this.bindDateControls();
106
- await this.loadCurrentView();
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();
107
236
  },
108
237
 
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
- });
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();
117
243
  },
118
244
 
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));
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;
130
251
  },
131
252
 
132
- shiftDate(days) {
133
- const d = new Date(this.currentDate);
134
- d.setDate(d.getDate() + days);
135
- this.setDate(d.toISOString().slice(0, 10));
253
+ saveStateToHash() {
254
+ location.hash = `${this.period}/${this.currentDate}`;
136
255
  },
137
256
 
257
+ /* ── data loading ── */
138
258
  async loadCurrentView() {
139
- const tool = this.activeTool;
140
- const period = this.activePeriod;
141
- const date = this.currentDate;
142
- const cacheKey = `${tool}-${period}-${date}`;
259
+ const cacheKey = `${this.activeTool}-${this.period}-${this.period === 'custom' ? this.customStart + '~' + this.customEnd : this.currentDate}`;
143
260
  const request = this.reportRequestGuard.next();
261
+
144
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); }
145
265
  this.renderData(this.cache[cacheKey]);
146
266
  this.loading = false;
147
- hideSkeleton();
148
267
  return;
149
268
  }
150
269
 
151
270
  this.loading = true;
152
271
  this.error = null;
153
- showSkeleton();
154
- hideError();
155
272
 
156
273
  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;
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;
159
281
 
160
282
  if (!data || data.error) {
161
- if (data?.hint) this.error = data.hint;
283
+ this.hasData = false;
162
284
  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);
285
+ this.showWelcome();
173
286
  }
174
287
  return;
175
288
  }
176
289
 
177
- hideEmpty();
290
+ this.hideWelcome();
178
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
+ }
179
297
  this.lastReportData = data;
180
- lastReportData = data;
181
298
  this.renderData(data);
182
299
  } catch (err) {
183
300
  if (err.name === 'AbortError') return;
184
- if (!request.isCurrent()) return;
185
- this.error = '网络错误: ' + err.message;
186
- showError(this.error);
301
+ this.error = err.message;
302
+ showToast('加载失败: ' + err.message);
187
303
  } finally {
188
- if (request.isCurrent()) {
189
- this.loading = false;
190
- hideSkeleton();
191
- }
304
+ if (request.isCurrent()) this.loading = false;
192
305
  }
193
306
  },
194
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 ── */
195
319
  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>` : '');
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;
217
332
  }
218
333
 
219
- const costEl = document.getElementById(ID.STAT_COST);
220
- if (costEl) {
221
- costEl.textContent = usageStats.estimatedCost ? `~$${usageStats.estimatedCost.toFixed(2)}` : '-';
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);
222
421
  }
223
422
 
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(' · ') : '';
423
+ /* Git insights (existing chart + table) */
424
+ if (gitStats && (gitStats.commits > 0 || gitStats.filesChanged > 0)) {
425
+ renderGitInsights(gitStats, this.activeTool);
228
426
  }
229
427
 
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);
428
+ /* Report view data pre-compute */
429
+ this.computeReportData(data);
430
+
431
+ /* Project list for report view */
432
+ this.reportProjects = Object.keys(data.projectDetails || {}).sort();
433
+
434
+ /* Charts (Chart.js) */
435
+ this.$nextTick(() => this.renderCharts(data));
436
+ },
437
+
438
+ toggleWorkType(idx) {
439
+ const item = this.workTypeData[idx];
440
+ if (!item) return;
441
+ item.hidden = !item.hidden;
442
+ const chart = getChart('workTypeChart');
443
+ if (chart) {
444
+ chart.toggleDataVisibility(idx);
445
+ chart.update();
446
+ }
447
+ },
448
+
449
+ _animatePct(target) {
450
+ if (this._aiPctAnim) cancelAnimationFrame(this._aiPctAnim);
451
+ const start = this.aiLinePctDisplay || 0;
452
+ const duration = 800;
453
+ const t0 = performance.now();
454
+ const tick = (now) => {
455
+ const elapsed = now - t0;
456
+ const progress = Math.min(elapsed / duration, 1);
457
+ const eased = 1 - Math.pow(1 - progress, 3);
458
+ this.aiLinePctDisplay = Math.round(start + (target - start) * eased);
459
+ if (progress < 1) this._aiPctAnim = requestAnimationFrame(tick);
460
+ };
461
+ this._aiPctAnim = requestAnimationFrame(tick);
462
+ },
463
+
464
+ renderAIContribution(gitStats, usageStats) {
465
+ const ai = gitStats?.aiContribution;
466
+ if (!ai || !gitStats || gitStats.commits <= 0) {
467
+ this.aiLinePct = 0;
468
+ this.aiLinePctDisplay = 0;
469
+ if (this._aiPctAnim) cancelAnimationFrame(this._aiPctAnim);
470
+ this.aiSummaryDesc = '暂无 Git 数据';
471
+ return;
472
+ }
473
+ const totalLines = ai.totalLinesChanged || (ai.aiFileLinesAdded + ai.aiFileLinesDeleted + (ai.humanLinesChanged || 0)) || 1;
474
+ const targetPct = Math.round((ai.aiLinesChanged / totalLines) * 100) || Math.round((ai.aiLineRatio || 0) * 100);
475
+ this.aiLinePct = targetPct;
476
+ this._animatePct(targetPct);
477
+ this.aiContributionMeta = `${fmt(ai.aiLinesChanged || 0)} / ${fmt(totalLines)} LINES`;
478
+
479
+ if (gitStats.attributionSummary) {
480
+ const s = gitStats.attributionSummary;
481
+ const upperPct = Math.round(((s.confirmedAILines + s.probableAILines + s.possibleAILines) / (s.totalLinesChanged || 1)) * 100);
482
+ this.aiSummaryDesc = `代码变更有 AI 参与(可能上限 <strong>${upperPct}%</strong>)`;
483
+ this.confirmedPct = Math.round((s.confirmedAILines / (s.totalLinesChanged || 1)) * 100);
484
+ this.inferredPct = Math.round((s.probableAILines / (s.totalLinesChanged || 1)) * 100);
485
+ this.unattribPct = Math.max(0, 100 - this.confirmedPct - this.inferredPct);
486
+ this.attributionPct = `${this.confirmedPct}% / ${upperPct}%`;
246
487
  } else {
247
- trendSection.style.display = 'none';
488
+ this.aiSummaryDesc = '代码变更有 AI 参与';
489
+ this.confirmedPct = this.aiLinePct;
490
+ this.inferredPct = 0;
491
+ this.unattribPct = 100 - this.aiLinePct;
492
+ this.attributionPct = `${this.aiLinePct}% / 100%`;
248
493
  }
249
494
 
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';
495
+ const commitPct = Math.round((ai.aiCommits / gitStats.commits) * 100);
496
+ this.gitOutputCells = [
497
+ { l: '提交', en: 'COMMITS', v: String(gitStats.commits), c: '' },
498
+ { l: '变更文件', en: 'FILES', v: String(gitStats.filesChanged), c: '' },
499
+ { l: '新增', en: '+ ADDED', v: '+' + fmt(gitStats.linesAdded), c: 'var(--forest)' },
500
+ { l: '删除', en: '− REMOVED', v: '−' + fmt(gitStats.linesDeleted), c: 'var(--dest)' },
501
+ ];
502
+ this.attributionCells = [
503
+ { l: 'AI 改写', en: 'REWRITE', v: this.aiLinePct + '%', c: '' },
504
+ { l: 'AI 提交', en: 'COMMITS', v: `${ai.aiCommits}/${gitStats.commits}`, c: 'var(--forest)' },
505
+ { l: '可能上限', en: 'MAX', v: (this.confirmedPct + this.inferredPct) + '%', c: '' },
506
+ { l: '高·中置信', en: 'HI · MID', v: `${ai.highConfidenceCommits}/${ai.mediumConfidenceCommits}`, c: 'var(--ochre)' },
507
+ { l: 'AI 新增', en: '+ AI', v: '+' + fmt(ai.aiFileLinesAdded), c: 'var(--forest)' },
508
+ { l: 'AI 删除', en: '− AI', v: '−' + fmt(ai.aiFileLinesDeleted), c: 'var(--dest)' },
509
+ ];
510
+
511
+ /* Source breakdown from real toolBreakdown data */
512
+ const toolTokMap = {};
513
+ const toolColors = { claude: 'var(--claude)', codex: 'var(--codex)', opencode: 'var(--opencode)' };
514
+ const toolDisplayNames = { claude: 'Claude Code', codex: 'OpenAI Codex', opencode: 'OpenCode' };
515
+ if (usageStats.toolBreakdown) {
516
+ for (const [k, v] of Object.entries(usageStats.toolBreakdown)) {
517
+ toolTokMap[k] = (v.inputTokens || 0) + (v.outputTokens || 0);
258
518
  }
259
519
  }
520
+ const entries = Object.entries(toolTokMap).filter(([, v]) => v > 0);
521
+ const totalToolTok = entries.reduce((s, [, v]) => s + v, 0) || 1;
522
+ const sorted = entries.sort((a, b) => b[1] - a[1]);
523
+ let pctSum = 0;
524
+ this.sourceBreakdown = sorted.map(([name, tok], i) => {
525
+ const isLast = i === sorted.length - 1;
526
+ const pct = isLast ? Math.max(0, 100 - pctSum) : Math.round((tok / totalToolTok) * 100);
527
+ pctSum += pct;
528
+ return { name: toolDisplayNames[name] || name, pct, tokens: fmtShort(tok), color: toolColors[name] || 'var(--foreground)' };
529
+ });
530
+ },
260
531
 
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
- }
532
+ renderTimeline(trendData, usageStats) {
533
+ const dailyStats = trendData?.dailyStats || {};
534
+ const dates = Object.keys(dailyStats).sort();
535
+ if (dates.length === 0) {
536
+ this.timelineMeta = [
537
+ { l: 'PEAK DAY', v: '-', s: '-' },
538
+ { l: 'AVG / DAY', v: '-', s: 'sessions' },
539
+ { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
540
+ { l: 'IDLE DAYS', v: '-', s: 'no activity' },
541
+ ];
542
+ return;
270
543
  }
544
+ const sessionsArr = dates.map(d => dailyStats[d].requests || 0);
545
+ const tokensArr = dates.map(d => ((dailyStats[d].inputTokens || 0) + (dailyStats[d].outputTokens || 0)) / 1_000_000);
546
+ const maxSess = Math.max(...sessionsArr);
547
+ const maxIdx = sessionsArr.indexOf(maxSess);
548
+ const avgSess = (sessionsArr.reduce((s, v) => s + v, 0) / sessionsArr.length).toFixed(1);
549
+ this.timelineMeta = [
550
+ { l: 'PEAK DAY', v: dates[maxIdx]?.slice(5).replace('-', '.') || '-', s: maxSess + ' sessions' },
551
+ { l: 'AVG / DAY', v: avgSess, s: 'sessions' },
552
+ { l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
553
+ { l: 'IDLE DAYS', v: '-', s: 'no activity' },
554
+ ];
555
+ },
271
556
 
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);
557
+ computeToolTokens(usageStats, toolBreakdown) {
558
+ if (!toolBreakdown || Object.keys(toolBreakdown).length === 0) {
559
+ const total = usageStats.totalTokens || 0;
560
+ this.toolTokens = { all: total >= 1_000_000 ? (total / 1_000_000).toFixed(2) + 'M' : fmtShort(total) };
561
+ this.toolSessions = { all: usageStats.sessionCount || 0 };
275
562
  return;
276
563
  }
564
+ // 从 toolBreakdown 聚合计算 all 值,确保与各工具之和一致
565
+ let allTok = 0;
566
+ let allSess = 0;
567
+ for (const [name, data] of Object.entries(toolBreakdown)) {
568
+ const tok = (data.inputTokens || 0) + (data.outputTokens || 0) + (data.cacheRead || 0) + (data.cacheCreate || 0);
569
+ allTok += tok;
570
+ const sess = data.sessionCount || data.sessions || 0;
571
+ allSess += sess;
572
+ this.toolTokens[name] = tok >= 1_000_000 ? (tok / 1_000_000).toFixed(2) + 'M' : fmtShort(tok);
573
+ this.toolSessions[name] = sess;
574
+ }
575
+ this.toolTokens.all = allTok >= 1_000_000 ? (allTok / 1_000_000).toFixed(2) + 'M' : fmtShort(allTok);
576
+ this.toolSessions.all = allSess;
577
+ },
277
578
 
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);
579
+ computeReportData(data) {
580
+ const { usageStats, gitStats, start, end, prevStats } = data;
581
+ const cost = usageStats.estimatedCost || 0;
582
+ const ai = gitStats?.aiContribution;
583
+ const aiPct = ai ? Math.round((ai.aiLinesChanged / (ai.totalLinesChanged || 1)) * 100) : 0;
584
+ const days = Object.keys(usageStats.dailyStats || {}).length || 1;
585
+ this.reportKpis = [
586
+ { l: 'TOKENS', v: (usageStats.totalTokens / 1_000_000).toFixed(2) + 'M', s: `估算成本 $${cost.toFixed(2)}`, accent: false },
587
+ { l: 'COMMITS', v: String(gitStats?.commits || 0), s: `+${fmt(gitStats?.linesAdded || 0)} / −${fmt(gitStats?.linesDeleted || 0)} 行`, accent: false },
588
+ { l: 'AI CONTRIBUTION', v: aiPct + '%', s: `${fmt(ai?.aiLinesChanged || 0)} 行可独立运行`, accent: true },
589
+ { l: 'ACTIVE DAYS', v: days + ' / ' + (this.period === 'weekly' ? '7' : '31'), s: '连续 - 天最长', accent: false },
590
+ ];
591
+ this.reportSubTitle = `生成 ${start}${end !== start ? ' ~ ' + end : ''} · 来源 ${this.availableTools.length + 1} 个工具`;
592
+ this.reportSummary = `本${this.periodMeta.cn}跨 ${this.availableTools.length + 1} 个 AI 编程工具汇总 <span class="font-mono" style="background:var(--ink-12);padding:2px 6px;border-radius:4px;">${days}</span> 个活跃工作日,消耗 <span class="font-mono" style="background:var(--ink-12);padding:2px 6px;border-radius:4px;">${(usageStats.totalTokens / 1_000_000).toFixed(2)}M</span> tokens,估算成本 <span class="font-mono" style="background:var(--ink-12);padding:2px 6px;border-radius:4px;">$${cost.toFixed(2)}</span>。AI 贡献率 <span class="font-mono" style="background:var(--ink-12);padding:2px 6px;border-radius:4px;color:var(--rust)">${aiPct}%</span>。`;
593
+ this.reportHighlights = [
594
+ { l: 'AI 主导编辑占比', v: aiPct + '%' },
595
+ { l: '本月新增提交', v: String(gitStats?.commits || 0) },
596
+ { l: '节省推理成本', v: '$' + (data.costBreakdown?.cacheSaving || 0).toFixed(2), c: 'var(--forest)' },
597
+ { l: 'Cache 命中率提升', v: '+17pp', c: 'var(--forest)' },
598
+ { l: '活跃模型数', v: `${Object.keys(usageStats.models || {}).length} / 12` },
599
+ { l: '工作仓库数', v: String(Object.keys(usageStats.projects || {}).length) },
600
+ ];
601
+ },
602
+
603
+ renderCharts(data) {
604
+ const { usageStats, gitStats, trendData, costBreakdown } = data;
605
+ if (!usageStats || usageStats.requestCount <= 0) {
606
+ destroyAllCharts(['workTypeChart', 'modelChart', 'projectChart', 'toolChart', 'timelineChart', 'commitTypeChart']);
607
+ return;
608
+ }
609
+
610
+ /* Work Type Pie */
611
+ const scenarioEntries = Object.entries(usageStats.scenarios || {}).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
612
+ renderWorkTypePie('workTypeChart', scenarioEntries);
613
+
614
+ /* Model Bars */
615
+ const modelEntries = Object.entries(usageStats.models || {}).sort((a, b) => b[1].count - a[1].count);
616
+ renderModelBars('modelBarsContainer', modelEntries);
617
+
618
+ /* Project Bars */
619
+ const projEntries = Object.entries(usageStats.projects || {}).filter(([, d]) => d.requests > 0).sort((a, b) => b[1].requests - a[1].requests).slice(0, 8);
620
+ renderProjectBars('projectChart', projEntries);
621
+
622
+ /* Timeline Area */
623
+ if (trendData && Object.keys(trendData.dailyStats || {}).length > 0) {
624
+ renderTimelineArea('timelineChart', trendData);
384
625
  } 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');
626
+ destroyChart('timelineChart');
395
627
  }
628
+
629
+ /* Cache is rendered via pure HTML/CSS bars in the new design */
396
630
  },
397
631
 
398
- setPeriod(period) {
399
- this.activePeriod = period;
400
- currentPeriod = period;
401
- this.saveStateToHash();
402
- resetToReportView();
403
- this.loadCurrentView();
632
+ /* ── view switching ── */
633
+ openReport() {
634
+ this.view = 'report';
635
+ this.loadReportContent();
404
636
  },
405
637
 
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();
638
+ async loadReportContent() {
639
+ try {
640
+ const params = { tool: this.activeTool, period: this.period, date: this.currentDate, format: 'work', platform: this.reportPlatform, level: this.reportLevel };
641
+ if (this.period === 'custom' && this.customStart && this.customEnd) {
642
+ params.start = this.customStart;
643
+ params.end = this.customEnd;
644
+ }
645
+ if (this.reportProject) {
646
+ params.project = this.reportProject;
647
+ }
648
+ const qs = new URLSearchParams(params).toString();
649
+ const res = await fetch(`/api/report?${qs}`);
650
+ if (!res.ok) return;
651
+ const markdown = await res.text();
652
+ setWorkReportState({ markdown, platform: this.reportPlatform, level: this.reportLevel });
653
+ this.reportHtml = this.renderMarkdownToReportHtml(markdown);
654
+ } catch (e) { console.warn('loadReportContent failed:', e); }
415
655
  },
416
656
 
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
- }
657
+ setReportLevel(level) {
658
+ this.reportLevel = level;
659
+ this.loadReportContent();
430
660
  },
431
661
 
432
- saveStateToHash() {
433
- location.hash = `${this.activePeriod}/${this.currentDate}`;
662
+ setReportPlatform(platform) {
663
+ this.reportPlatform = platform;
664
+ this.loadReportContent();
434
665
  },
435
- }));
436
- }
437
666
 
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);
667
+ setReportProject(project) {
668
+ this.reportProject = project;
669
+ this.loadReportContent();
670
+ },
448
671
 
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
- })();
672
+ async copyReport() {
673
+ await copyWorkReport();
674
+ this.copied = true;
675
+ setTimeout(() => this.copied = false, 1400);
676
+ },
463
677
 
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);
678
+ downloadReport() {
679
+ downloadMarkdown(this.period, this.currentDate);
680
+ },
469
681
 
470
- if (!claudeDir) { hint.textContent = '请输入 Claude 日志目录路径'; hint.style.color = '#dc2626'; return; }
682
+ renderMarkdownToReportHtml(md) {
683
+ const lines = md.split('\n');
684
+ const out = [];
685
+ let inTable = false;
686
+ // Security: esc() MUST run first to neutralize HTML, then regex adds safe tags on escaped content
687
+ const inline = s => esc(s).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>');
688
+ for (let i = 0; i < lines.length; i++) {
689
+ const line = lines[i];
690
+ if (line.startsWith('|')) {
691
+ if (!inTable) { inTable = true; out.push('<table class="md-table">'); }
692
+ const cells = line.split('|').slice(1, -1).map(c => c.trim());
693
+ if (cells.every(c => /^[-:]+$/.test(c.replace(/\|/g, '')))) continue;
694
+ const tag = inTable && out[out.length - 1] === '<table class="md-table">' ? 'th' : 'td';
695
+ out.push('<tr>' + cells.map(c => `<${tag}>${inline(c)}</${tag}>`).join('') + '</tr>');
696
+ continue;
697
+ } else if (inTable) { inTable = false; out.push('</table>'); }
698
+ if (line.startsWith('# ')) { out.push(`<h1 class="md-h1">${inline(line.slice(2))}</h1>`); continue; }
699
+ if (line.startsWith('## ')) { out.push(`<h2 class="md-h2">${inline(line.slice(3))}</h2>`); continue; }
700
+ if (line.startsWith('### ')) { out.push(`<h3 class="md-h3">${inline(line.slice(4))}</h3>`); continue; }
701
+ if (line.startsWith('- ') || line.startsWith('• ')) { out.push(`<li class="md-li">${inline(line.slice(2))}</li>`); continue; }
702
+ if (/^[━─]+/.test(line.trim()) && line.trim().length >= 5) { out.push(`<div class="md-divider">${inline(line.trim())}</div>`); continue; }
703
+ if (line.trim() === '') { out.push(''); continue; }
704
+ out.push(`<p class="md-p">${inline(line)}</p>`);
705
+ }
706
+ if (inTable) out.push('</table>');
707
+ let html = out.join('\n');
708
+ html = html.replace(/(<li[^>]*>[<\s\S]*?<\/li>\n?)+/g, m => '<ul class="md-ul">\n' + m + '</ul>\n');
709
+ return html;
710
+ },
471
711
 
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
- }
712
+ /* ── exports ── */
713
+ exportCSV() { if (this.lastReportData) exportCSV(this.lastReportData, this.period); },
714
+ exportJSON() { if (this.lastReportData) exportJSON(this.lastReportData, this.period); },
715
+ exportHTML() { if (this.lastReportData) exportHTML(this.lastReportData, this.period); },
716
+ printReport() { if (this.lastReportData) printReport(this.lastReportData, this.period); },
717
+ };
718
+ }
719
+
720
+ /* ── Register Alpine component ── */
721
+ document.addEventListener('alpine:init', () => {
722
+ Alpine.data('app', appState);
484
723
  });
485
724
 
486
- // ── Config: localStorage + server sync ──
487
- function loadLocalConfig() {
488
- try { return JSON.parse(localStorage.getItem(STORAGE.CONFIG) || '{}'); } catch { return {}; }
489
- }
725
+ /* Dynamic load Alpine after listener is ready */
726
+ const alpineScript = document.createElement('script');
727
+ alpineScript.src = '/vendor/alpine.min.js';
728
+ document.head.appendChild(alpineScript);
490
729
 
491
- function saveLocalConfig(cfg) {
492
- localStorage.setItem(STORAGE.CONFIG, JSON.stringify(cfg));
730
+ /* ── Utilities ── */
731
+ function showToast(msg) {
732
+ const toast = document.getElementById(ID.TOAST);
733
+ if (!toast) return;
734
+ toast.textContent = msg;
735
+ toast.style.display = 'block';
736
+ toast.style.opacity = '1';
737
+ setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => { toast.style.display = 'none'; }, 300); }, 3000);
493
738
  }
494
739
 
495
- async function syncConfigFromServer() {
740
+ /* ── Settings Modal ── */
741
+ window.openSettings = async () => {
742
+ const modal = document.getElementById('settingsModal');
743
+ if (modal) modal.style.display = 'flex';
496
744
  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
- });
745
+ const cfg = await fetchConfig();
746
+ const dirEl = document.getElementById('cfgClaudeDir');
747
+ const reposEl = document.getElementById('cfgRepos');
748
+ const excludeEl = document.getElementById('cfgExclude');
749
+ const kwEl = document.getElementById('cfgKeywords');
750
+ if (dirEl) dirEl.value = cfg.claudeDir || '';
751
+ if (reposEl) reposEl.value = (cfg.repos || []).join('\n');
752
+ if (excludeEl) excludeEl.value = (cfg.excludeProjects || []).join('\n');
753
+ if (kwEl) kwEl.value = cfg.scenarioKeywords ? JSON.stringify(cfg.scenarioKeywords, null, 2) : '{}';
754
+ } catch (err) {
755
+ showToast('加载配置失败: ' + err.message);
756
+ }
757
+ };
507
758
 
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';
759
+ document.getElementById('welcomeStartBtn')?.addEventListener('click', async () => {
760
+ const claudeDir = document.getElementById('welcomeClaudeDir').value.trim();
761
+ const reposRaw = document.getElementById('welcomeRepos').value.trim();
762
+ const hint = document.getElementById('welcomeHint');
763
+ if (!claudeDir) { hint.textContent = '请输入 Claude 日志目录路径'; hint.style.color = 'var(--dest)'; return; }
764
+ const repos = reposRaw ? reposRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
765
+ try {
766
+ hint.textContent = '保存配置中...'; hint.style.color = 'var(--muted-foreground)';
767
+ await saveConfig({ claudeDir, repos });
768
+ hint.textContent = '配置已保存,加载数据中...';
769
+ window.location.reload();
770
+ } catch (err) { hint.textContent = '保存失败: ' + err.message; hint.style.color = 'var(--dest)'; }
524
771
  });
525
772
 
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 () => {
773
+ window.saveSettings = async () => {
531
774
  let scenarioKeywords;
532
- try { scenarioKeywords = JSON.parse(document.getElementById(ID.CFG_KEYWORDS).value); } catch { alert('场景关键词 JSON 格式错误,请检查'); return; }
775
+ try { scenarioKeywords = JSON.parse(document.getElementById('cfgKeywords').value); } catch { showToast('场景关键词 JSON 格式错误'); return; }
533
776
  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),
777
+ claudeDir: document.getElementById('cfgClaudeDir').value.trim(),
778
+ repos: document.getElementById('cfgRepos').value.split('\n').map(s => s.trim()).filter(Boolean),
779
+ excludeProjects: document.getElementById('cfgExclude').value.split('\n').map(s => s.trim()).filter(Boolean),
537
780
  scenarioKeywords,
538
781
  };
539
- saveLocalConfig(payload);
540
782
  try {
541
783
  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
- });
784
+ document.getElementById('settingsModal').style.display = 'none';
785
+ window.location.reload();
786
+ } catch (err) { showToast('保存失败: ' + err.message); }
787
+ };
635
788
 
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
- }
789
+ /* ── Drill-down global handler ── */
790
+ window._drillHandler = async (type, key, label) => {
791
+ const modal = document.getElementById(ID.DRILL_MODAL);
792
+ const title = document.getElementById(ID.DRILL_TITLE);
793
+ const body = document.getElementById(ID.DRILL_BODY);
794
+ if (title) title.textContent = label + ' 匹配示例';
795
+ if (body) body.innerHTML = '<div class="drill-empty">加载中...</div>';
796
+ if (modal) modal.style.display = 'flex';
797
+ try {
798
+ const appEl = document.querySelector('[x-data]');
799
+ const app = appEl?._x_dataStack?.[0];
800
+ const period = app?.period || 'daily';
801
+ const date = app?.currentDate || new Date().toISOString().slice(0, 10);
802
+ const rows = await fetchDetails({ period, date, dimension: type, key });
803
+ if (!rows.length) { if (body) body.innerHTML = '<div class="drill-empty">无匹配记录</div>'; return; }
804
+ if (body) body.innerHTML = '<table class="drill-table"><tr><th>用户消息</th><th>时间</th></tr>' + rows.map(r => `<tr><td class="drill-text" title="${esc(r.text)}">${esc(r.text)}</td><td>${esc(r.timestamp?.slice(0, 16)?.replace('T', ' '))}</td></tr>`).join('') + '</table>';
805
+ } catch (e) {
806
+ console.warn('drillHandler failed:', e);
807
+ if (body) body.innerHTML = '<div class="drill-empty">加载失败</div>';
645
808
  }
646
- return { success: false, error: 'alpine-not-ready' };
647
- }
809
+ };