lumencode 0.4.4 → 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/README.md +54 -38
- package/index.js +79 -10
- package/lib/aggregate.js +40 -6
- package/lib/cache.js +8 -0
- package/lib/git.js +24 -2
- package/lib/report.js +14 -14
- package/lib/server.js +523 -412
- package/package.json +1 -1
- package/public/api.js +6 -0
- package/public/app.js +699 -526
- package/public/charts.js +285 -95
- package/public/config.js +22 -21
- package/public/git-insights.js +39 -113
- package/public/index.html +728 -341
- package/public/style.css +829 -1702
- package/public/ui-state.js +8 -67
- package/public/utils.js +10 -0
- package/public/work-report.js +1 -22
package/public/app.js
CHANGED
|
@@ -1,636 +1,809 @@
|
|
|
1
|
-
import { COLORS, TEXT, ID, STORAGE } from './config.js';
|
|
2
|
-
import { esc, fmt, fmtShort,
|
|
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 {
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
this.
|
|
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
|
-
|
|
194
|
+
this.loadCurrentView();
|
|
195
|
+
if (this.view === 'report') this.loadReportContent();
|
|
76
196
|
},
|
|
77
197
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
105
|
-
this.
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
124
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
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
|
|
158
|
-
if (
|
|
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
|
-
|
|
283
|
+
this.hasData = false;
|
|
162
284
|
if (data?.error === TEXT.NOT_CONFIGURED) {
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
225
|
-
if (
|
|
226
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
}
|
|
428
|
+
/* Report view data pre-compute */
|
|
429
|
+
this.computeReportData(data);
|
|
249
430
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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();
|
|
259
446
|
}
|
|
447
|
+
},
|
|
260
448
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 数据';
|
|
264
471
|
return;
|
|
265
472
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const model = modelEntries[elements[0].index][0];
|
|
281
|
-
showDrill(esc(model), '<div class="drill-empty">加载中...</div>');
|
|
282
|
-
try {
|
|
283
|
-
const rows = await fetchDetails({ period: this.activePeriod, date: this.currentDate, dimension: 'model', key: model });
|
|
284
|
-
if (!rows.length) { showDrill(esc(model), '<div class="drill-empty">无数据</div>'); return; }
|
|
285
|
-
showDrill(esc(model) + ' 按日分布', '<table class="drill-table"><tr><th>日期</th><th>请求数</th><th>输入Token</th><th>输出Token</th></tr>' + rows.map(r => `<tr><td>${esc(r.date)}</td><td>${r.requests}</td><td>${fmtShort(r.inputTokens)}</td><td>${fmtShort(r.outputTokens)}</td></tr>`).join('') + '</table>');
|
|
286
|
-
} catch {}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
});
|
|
290
|
-
setChart(ID.MODEL_CHART, modelChart);
|
|
291
|
-
|
|
292
|
-
// Projects (with drill-down)
|
|
293
|
-
const projEntries = Object.entries(usageStats.projects).filter(([, d]) => d.requests > 0).sort((a, b) => b[1].requests - a[1].requests).slice(0, 8);
|
|
294
|
-
destroyChart(ID.PROJECT_CHART);
|
|
295
|
-
const projCtx = document.getElementById(ID.PROJECT_CHART).getContext('2d');
|
|
296
|
-
const projChart = new Chart(projCtx, {
|
|
297
|
-
type: 'bar',
|
|
298
|
-
data: { labels: projEntries.map(([k]) => k.length > 20 ? '...' + k.slice(-17) : k), datasets: [{ label: '请求数', data: projEntries.map(([, v]) => v.requests), backgroundColor: '#374151', borderRadius: 6, maxBarThickness: 20, barPercentage: 0.7 }] },
|
|
299
|
-
options: { responsive: true, maintainAspectRatio: false, indexAxis: 'y', scales: { x: { grid: { color: '#f3f4f6' }, ticks: { font: { family: 'Inter', size: 11 } } }, y: { grid: { display: false }, ticks: { font: { family: 'Inter', size: 12 } } } }, plugins: { legend: { display: false } },
|
|
300
|
-
onClick: async (evt, elements) => {
|
|
301
|
-
if (elements.length === 0) return;
|
|
302
|
-
const project = projEntries[elements[0].index][0];
|
|
303
|
-
showDrill(esc(project), '<div class="drill-empty">加载中...</div>');
|
|
304
|
-
try {
|
|
305
|
-
const params = { project, period: this.activePeriod, date: this.currentDate };
|
|
306
|
-
if (this.activeTool !== 'all') params.tool = this.activeTool;
|
|
307
|
-
const rows = await fetchSessions(params);
|
|
308
|
-
if (!rows.length) { showDrill(esc(project), '<div class="drill-empty">无数据</div>'); return; }
|
|
309
|
-
const html = '<table class="drill-table">'
|
|
310
|
-
+ '<tr><th></th><th>会话ID</th><th>开始</th><th>时长</th><th>请求</th><th>工具</th><th>文件</th><th>提交</th></tr>'
|
|
311
|
-
+ rows.map((r, i) => {
|
|
312
|
-
const start = r.startTime ? r.startTime.slice(0, 16).replace('T', ' ') : '-';
|
|
313
|
-
const dur = r.duration ? (r.duration >= 3600 ? (r.duration / 3600).toFixed(1) + 'h' : r.duration >= 60 ? Math.round(r.duration / 60) + 'm' : r.duration + 's') : '-';
|
|
314
|
-
const cn = r.commits?.length || 0;
|
|
315
|
-
const toggle = cn > 0 ? `<button class="commit-toggle" data-idx="${i}">▸</button>` : '';
|
|
316
|
-
const tools = [...new Set(r.toolSequence || [])].slice(0, 3).join(', ');
|
|
317
|
-
const fileCount = r.touchedFileCount || 0;
|
|
318
|
-
const commitRows = cn > 0
|
|
319
|
-
? `<tr class="commit-subrow" data-idx="${i}" style="display:none;"><td colspan="8"><table class="commit-subtable">
|
|
320
|
-
<tr><th>hash</th><th>type</th><th>subject</th><th class="num">+行</th><th class="num">-行</th><th>AI</th><th>证据</th></tr>
|
|
321
|
-
${r.commits.map(c => `<tr>
|
|
322
|
-
<td class="hash"><code>${c.hash.slice(0,7)}</code></td>
|
|
323
|
-
<td><span class="commit-type-tag type-${c.type}">${c.type}</span></td>
|
|
324
|
-
<td class="commit-subject" title="${esc(c.subject)}">${esc(c.subject)}</td>
|
|
325
|
-
<td class="num pos">+${fmt(c.linesAdded || 0)}</td>
|
|
326
|
-
<td class="num neg">-${fmt(c.linesDeleted || 0)}</td>
|
|
327
|
-
<td>${c.aiConfidence === 'high' ? 'H' : c.aiConfidence === 'medium' ? 'M' : c.aiConfidence === 'low' ? 'L' : ''}</td>
|
|
328
|
-
<td>${c.aiEvidenceDetails?.matchedFileCount ? `文件交集 ${c.aiEvidenceDetails.matchedFileCount}` : (c.attributionType || '')}</td>
|
|
329
|
-
</tr>`).join('')}
|
|
330
|
-
</table></td></tr>`
|
|
331
|
-
: '';
|
|
332
|
-
return `<tr><td>${toggle}</td><td class="drill-text" title="${esc(r.id)}">${esc(r.id)}</td><td>${start}</td><td>${dur}</td><td>${r.requests || '-'}</td><td class="drill-text">${tools || '-'}</td><td>${fileCount || '-'}</td><td>${cn || '-'}</td></tr>${commitRows}`;
|
|
333
|
-
}).join('')
|
|
334
|
-
+ '</table>';
|
|
335
|
-
showDrill(esc(project) + ' 会话记录', html);
|
|
336
|
-
document.querySelectorAll('.commit-toggle').forEach(btn => {
|
|
337
|
-
btn.addEventListener('click', () => {
|
|
338
|
-
const idx = btn.dataset.idx;
|
|
339
|
-
const sub = document.querySelector(`.commit-subrow[data-idx="${idx}"]`);
|
|
340
|
-
const open = sub.style.display !== 'none';
|
|
341
|
-
sub.style.display = open ? 'none' : '';
|
|
342
|
-
btn.textContent = open ? '▸' : '▾';
|
|
343
|
-
});
|
|
344
|
-
});
|
|
345
|
-
} catch {}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
});
|
|
349
|
-
setChart(ID.PROJECT_CHART, projChart);
|
|
350
|
-
|
|
351
|
-
// Tools
|
|
352
|
-
const toolEntries = Object.entries(usageStats.tools).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
353
|
-
renderBar(ID.TOOL_CHART, toolEntries.map(([k]) => k), toolEntries.map(([, v]) => v), '调用次数');
|
|
354
|
-
|
|
355
|
-
this.updateGitPanel(gitStats, this.activeTool, reposConfigured);
|
|
356
|
-
},
|
|
357
|
-
|
|
358
|
-
updateGitPanel(gitStats, activeTool = 'all', reposConfigured = false) {
|
|
359
|
-
const gitSection = document.getElementById(ID.GIT_SECTION);
|
|
360
|
-
const gitInsightsRow = document.getElementById(ID.GIT_INSIGHTS_ROW);
|
|
361
|
-
const gitConfigured = gitStats !== null || reposConfigured;
|
|
362
|
-
const hasGit = gitStats && (gitStats.commits > 0 || gitStats.filesChanged > 0);
|
|
363
|
-
if (hasGit) {
|
|
364
|
-
gitSection.style.display = 'block';
|
|
365
|
-
gitSection.dataset.hasGit = 'true';
|
|
366
|
-
document.getElementById(ID.GIT_STATS).innerHTML = `
|
|
367
|
-
<div class="git-stat-item"><div class="git-stat-value">${fmt(gitStats.commits)}</div><div class="git-stat-label">提交次数</div></div>
|
|
368
|
-
<div class="git-stat-item"><div class="git-stat-value">+${fmt(gitStats.linesAdded)}</div><div class="git-stat-label">新增行数</div></div>
|
|
369
|
-
<div class="git-stat-item"><div class="git-stat-value">-${fmt(gitStats.linesDeleted)}</div><div class="git-stat-label">删除行数</div></div>
|
|
370
|
-
<div class="git-stat-item"><div class="git-stat-value">${fmt(gitStats.filesChanged)}</div><div class="git-stat-label">变更文件</div></div>
|
|
371
|
-
`;
|
|
372
|
-
renderGitInsights(gitStats, activeTool);
|
|
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}%`;
|
|
373
487
|
} else {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
document.getElementById(ID.GIT_STATS).innerHTML = `<div style="text-align:center;padding:16px 0;grid-column:1/-1;"><p style="color:var(--muted);margin-bottom:12px;">配置本地项目路径后,可在此查看 Git 代码产出</p><button class="btn-outline" onclick="document.getElementById('${ID.SETTINGS_BTN}').click()">配置项目路径</button></div>`;
|
|
380
|
-
}
|
|
381
|
-
document.getElementById(ID.GIT_AI_STATS).innerHTML = '';
|
|
382
|
-
if (gitInsightsRow) gitInsightsRow.style.display = 'none';
|
|
383
|
-
destroyChart('commitTypeChart');
|
|
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%`;
|
|
384
493
|
}
|
|
385
|
-
},
|
|
386
494
|
|
|
387
|
-
|
|
388
|
-
this.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
495
|
+
const commitPct = Math.round((ai.aiCommits / gitStats.commits) * 100);
|
|
496
|
+
this.gitOutputCells = [
|
|
497
|
+
{ l: '提交', en: 'COMMITS', v: String(gitStats.commits), c: '' },
|
|
498
|
+
{ l: '变更文件', en: 'FILES', v: String(gitStats.filesChanged), c: '' },
|
|
499
|
+
{ l: '新增', en: '+ ADDED', v: '+' + fmt(gitStats.linesAdded), c: 'var(--forest)' },
|
|
500
|
+
{ l: '删除', en: '− REMOVED', v: '−' + fmt(gitStats.linesDeleted), c: 'var(--dest)' },
|
|
501
|
+
];
|
|
502
|
+
this.attributionCells = [
|
|
503
|
+
{ l: 'AI 改写', en: 'REWRITE', v: this.aiLinePct + '%', c: '' },
|
|
504
|
+
{ l: 'AI 提交', en: 'COMMITS', v: `${ai.aiCommits}/${gitStats.commits}`, c: 'var(--forest)' },
|
|
505
|
+
{ l: '可能上限', en: 'MAX', v: (this.confirmedPct + this.inferredPct) + '%', c: '' },
|
|
506
|
+
{ l: '高·中置信', en: 'HI · MID', v: `${ai.highConfidenceCommits}/${ai.mediumConfidenceCommits}`, c: 'var(--ochre)' },
|
|
507
|
+
{ l: 'AI 新增', en: '+ AI', v: '+' + fmt(ai.aiFileLinesAdded), c: 'var(--forest)' },
|
|
508
|
+
{ l: 'AI 删除', en: '− AI', v: '−' + fmt(ai.aiFileLinesDeleted), c: 'var(--dest)' },
|
|
509
|
+
];
|
|
510
|
+
|
|
511
|
+
/* Source breakdown from real toolBreakdown data */
|
|
512
|
+
const toolTokMap = {};
|
|
513
|
+
const toolColors = { claude: 'var(--claude)', codex: 'var(--codex)', opencode: 'var(--opencode)' };
|
|
514
|
+
const toolDisplayNames = { claude: 'Claude Code', codex: 'OpenAI Codex', opencode: 'OpenCode' };
|
|
515
|
+
if (usageStats.toolBreakdown) {
|
|
516
|
+
for (const [k, v] of Object.entries(usageStats.toolBreakdown)) {
|
|
517
|
+
toolTokMap[k] = (v.inputTokens || 0) + (v.outputTokens || 0);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const entries = Object.entries(toolTokMap).filter(([, v]) => v > 0);
|
|
521
|
+
const totalToolTok = entries.reduce((s, [, v]) => s + v, 0) || 1;
|
|
522
|
+
const sorted = entries.sort((a, b) => b[1] - a[1]);
|
|
523
|
+
let pctSum = 0;
|
|
524
|
+
this.sourceBreakdown = sorted.map(([name, tok], i) => {
|
|
525
|
+
const isLast = i === sorted.length - 1;
|
|
526
|
+
const pct = isLast ? Math.max(0, 100 - pctSum) : Math.round((tok / totalToolTok) * 100);
|
|
527
|
+
pctSum += pct;
|
|
528
|
+
return { name: toolDisplayNames[name] || name, pct, tokens: fmtShort(tok), color: toolColors[name] || 'var(--foreground)' };
|
|
529
|
+
});
|
|
393
530
|
},
|
|
394
531
|
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
532
|
+
renderTimeline(trendData, usageStats) {
|
|
533
|
+
const dailyStats = trendData?.dailyStats || {};
|
|
534
|
+
const dates = Object.keys(dailyStats).sort();
|
|
535
|
+
if (dates.length === 0) {
|
|
536
|
+
this.timelineMeta = [
|
|
537
|
+
{ l: 'PEAK DAY', v: '-', s: '-' },
|
|
538
|
+
{ l: 'AVG / DAY', v: '-', s: 'sessions' },
|
|
539
|
+
{ l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
|
|
540
|
+
{ l: 'IDLE DAYS', v: '-', s: 'no activity' },
|
|
541
|
+
];
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const sessionsArr = dates.map(d => dailyStats[d].requests || 0);
|
|
545
|
+
const tokensArr = dates.map(d => ((dailyStats[d].inputTokens || 0) + (dailyStats[d].outputTokens || 0)) / 1_000_000);
|
|
546
|
+
const maxSess = Math.max(...sessionsArr);
|
|
547
|
+
const maxIdx = sessionsArr.indexOf(maxSess);
|
|
548
|
+
const avgSess = (sessionsArr.reduce((s, v) => s + v, 0) / sessionsArr.length).toFixed(1);
|
|
549
|
+
this.timelineMeta = [
|
|
550
|
+
{ l: 'PEAK DAY', v: dates[maxIdx]?.slice(5).replace('-', '.') || '-', s: maxSess + ' sessions' },
|
|
551
|
+
{ l: 'AVG / DAY', v: avgSess, s: 'sessions' },
|
|
552
|
+
{ l: 'LONGEST STREAK', v: '-', s: 'consecutive days' },
|
|
553
|
+
{ l: 'IDLE DAYS', v: '-', s: 'no activity' },
|
|
554
|
+
];
|
|
404
555
|
},
|
|
405
556
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
currentPeriod = p;
|
|
413
|
-
document.querySelectorAll('.category-tab').forEach(b => b.classList.toggle('active', b.dataset.period === p));
|
|
557
|
+
computeToolTokens(usageStats, toolBreakdown) {
|
|
558
|
+
if (!toolBreakdown || Object.keys(toolBreakdown).length === 0) {
|
|
559
|
+
const total = usageStats.totalTokens || 0;
|
|
560
|
+
this.toolTokens = { all: total >= 1_000_000 ? (total / 1_000_000).toFixed(2) + 'M' : fmtShort(total) };
|
|
561
|
+
this.toolSessions = { all: usageStats.sessionCount || 0 };
|
|
562
|
+
return;
|
|
414
563
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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;
|
|
418
574
|
}
|
|
575
|
+
this.toolTokens.all = allTok >= 1_000_000 ? (allTok / 1_000_000).toFixed(2) + 'M' : fmtShort(allTok);
|
|
576
|
+
this.toolSessions.all = allSess;
|
|
419
577
|
},
|
|
420
578
|
|
|
421
|
-
|
|
422
|
-
|
|
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
|
+
];
|
|
423
601
|
},
|
|
424
|
-
}));
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// ── Alpine 加载 ──
|
|
428
|
-
// Alpine 通过 queueMicrotask(start) 自动初始化
|
|
429
|
-
// 我们需要在 Alpine.start() 之前注册 Alpine.data()
|
|
430
|
-
// 方案:在 Alpine 脚本标签之前设置 alpine:init 监听器
|
|
431
|
-
// 用 inline script 在 head 中捕获 alpine:init 事件
|
|
432
|
-
document.addEventListener('alpine:init', registerAlpineComponents);
|
|
433
|
-
// 动态加载 Alpine(不在 HTML 预加载,确保我们的监听器先就位)
|
|
434
|
-
const alpineScript = document.createElement('script');
|
|
435
|
-
alpineScript.src = '/vendor/alpine.min.js';
|
|
436
|
-
document.head.appendChild(alpineScript);
|
|
437
602
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
currentPeriod = p;
|
|
445
|
-
document.querySelectorAll('.category-tab').forEach(b => b.classList.toggle('active', b.dataset.period === p));
|
|
446
|
-
}
|
|
447
|
-
if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) {
|
|
448
|
-
currentDate = d;
|
|
449
|
-
document.getElementById(ID.DATE_INPUT).value = d;
|
|
450
|
-
}
|
|
451
|
-
})();
|
|
452
|
-
|
|
453
|
-
// ── Welcome page config ──
|
|
454
|
-
document.getElementById(ID.WELCOME_START_BTN)?.addEventListener('click', async () => {
|
|
455
|
-
const claudeDir = document.getElementById(ID.WELCOME_CLAUDE_DIR).value.trim();
|
|
456
|
-
const reposRaw = document.getElementById(ID.WELCOME_REPOS).value.trim();
|
|
457
|
-
const hint = document.getElementById(ID.WELCOME_HINT);
|
|
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
|
+
}
|
|
458
609
|
|
|
459
|
-
|
|
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);
|
|
460
613
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
hint.style.color = 'var(--muted)';
|
|
465
|
-
await saveConfig({ claudeDir, repos });
|
|
466
|
-
hint.textContent = '配置已保存,加载数据中...';
|
|
467
|
-
hideEmpty();
|
|
468
|
-
await loadData();
|
|
469
|
-
} catch (err) {
|
|
470
|
-
hint.textContent = '保存失败: ' + err.message;
|
|
471
|
-
hint.style.color = '#dc2626';
|
|
472
|
-
}
|
|
473
|
-
});
|
|
614
|
+
/* Model Bars */
|
|
615
|
+
const modelEntries = Object.entries(usageStats.models || {}).sort((a, b) => b[1].count - a[1].count);
|
|
616
|
+
renderModelBars('modelBarsContainer', modelEntries);
|
|
474
617
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
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);
|
|
479
621
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
622
|
+
/* Timeline Area */
|
|
623
|
+
if (trendData && Object.keys(trendData.dailyStats || {}).length > 0) {
|
|
624
|
+
renderTimelineArea('timelineChart', trendData);
|
|
625
|
+
} else {
|
|
626
|
+
destroyChart('timelineChart');
|
|
627
|
+
}
|
|
483
628
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const serverCfg = await fetchConfig();
|
|
487
|
-
const localCfg = loadLocalConfig();
|
|
488
|
-
saveLocalConfig({ ...localCfg, ...serverCfg });
|
|
489
|
-
} catch {}
|
|
490
|
-
}
|
|
629
|
+
/* Cache is rendered via pure HTML/CSS bars in the new design */
|
|
630
|
+
},
|
|
491
631
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
632
|
+
/* ── view switching ── */
|
|
633
|
+
openReport() {
|
|
634
|
+
this.view = 'report';
|
|
635
|
+
this.loadReportContent();
|
|
636
|
+
},
|
|
496
637
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
});
|
|
638
|
+
async loadReportContent() {
|
|
639
|
+
try {
|
|
640
|
+
const params = { tool: this.activeTool, period: this.period, date: this.currentDate, format: 'work', platform: this.reportPlatform, level: this.reportLevel };
|
|
641
|
+
if (this.period === 'custom' && this.customStart && this.customEnd) {
|
|
642
|
+
params.start = this.customStart;
|
|
643
|
+
params.end = this.customEnd;
|
|
644
|
+
}
|
|
645
|
+
if (this.reportProject) {
|
|
646
|
+
params.project = this.reportProject;
|
|
647
|
+
}
|
|
648
|
+
const qs = new URLSearchParams(params).toString();
|
|
649
|
+
const res = await fetch(`/api/report?${qs}`);
|
|
650
|
+
if (!res.ok) return;
|
|
651
|
+
const markdown = await res.text();
|
|
652
|
+
setWorkReportState({ markdown, platform: this.reportPlatform, level: this.reportLevel });
|
|
653
|
+
this.reportHtml = this.renderMarkdownToReportHtml(markdown);
|
|
654
|
+
} catch (e) { console.warn('loadReportContent failed:', e); }
|
|
655
|
+
},
|
|
514
656
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
657
|
+
setReportLevel(level) {
|
|
658
|
+
this.reportLevel = level;
|
|
659
|
+
this.loadReportContent();
|
|
660
|
+
},
|
|
518
661
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
claudeDir: document.getElementById(ID.CFG_CLAUDE_DIR).value.trim(),
|
|
524
|
-
repos: document.getElementById(ID.CFG_REPOS).value.split('\n').map(s => s.trim()).filter(Boolean),
|
|
525
|
-
excludeProjects: document.getElementById(ID.CFG_EXCLUDE).value.split('\n').map(s => s.trim()).filter(Boolean),
|
|
526
|
-
scenarioKeywords,
|
|
527
|
-
};
|
|
528
|
-
saveLocalConfig(payload);
|
|
529
|
-
try {
|
|
530
|
-
await saveConfig(payload);
|
|
531
|
-
const appEl = document.querySelector('[x-data="app()"]');
|
|
532
|
-
if (appEl && appEl._x_dataStack) {
|
|
533
|
-
const app = appEl._x_dataStack[0];
|
|
534
|
-
if (app && app.cache) app.cache = {};
|
|
535
|
-
}
|
|
536
|
-
await loadData();
|
|
537
|
-
hideSettings();
|
|
538
|
-
} catch (err) {
|
|
539
|
-
alert('保存失败: ' + err.message);
|
|
540
|
-
}
|
|
541
|
-
});
|
|
662
|
+
setReportPlatform(platform) {
|
|
663
|
+
this.reportPlatform = platform;
|
|
664
|
+
this.loadReportContent();
|
|
665
|
+
},
|
|
542
666
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
667
|
+
setReportProject(project) {
|
|
668
|
+
this.reportProject = project;
|
|
669
|
+
this.loadReportContent();
|
|
670
|
+
},
|
|
547
671
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
});
|
|
554
|
-
});
|
|
672
|
+
async copyReport() {
|
|
673
|
+
await copyWorkReport();
|
|
674
|
+
this.copied = true;
|
|
675
|
+
setTimeout(() => this.copied = false, 1400);
|
|
676
|
+
},
|
|
555
677
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
btn.classList.add('active');
|
|
560
|
-
loadWorkReport(fetch, currentTool, currentPeriod, currentDate, btn.dataset.platform, null);
|
|
561
|
-
});
|
|
562
|
-
});
|
|
678
|
+
downloadReport() {
|
|
679
|
+
downloadMarkdown(this.period, this.currentDate);
|
|
680
|
+
},
|
|
563
681
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
+
},
|
|
567
711
|
|
|
568
|
-
|
|
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
|
+
}
|
|
569
719
|
|
|
570
|
-
|
|
571
|
-
document.
|
|
572
|
-
|
|
573
|
-
if (drillModal) drillModal.style.display = 'none';
|
|
574
|
-
});
|
|
575
|
-
document.getElementById(ID.DRILL_MODAL)?.querySelector('.modal-backdrop')?.addEventListener('click', () => {
|
|
576
|
-
const drillModal = document.getElementById(ID.DRILL_MODAL);
|
|
577
|
-
if (drillModal) drillModal.style.display = 'none';
|
|
720
|
+
/* ── Register Alpine component ── */
|
|
721
|
+
document.addEventListener('alpine:init', () => {
|
|
722
|
+
Alpine.data('app', appState);
|
|
578
723
|
});
|
|
579
724
|
|
|
580
|
-
|
|
581
|
-
document.
|
|
582
|
-
|
|
583
|
-
document.
|
|
584
|
-
document.getElementById(ID.EXPORT_HTML_BTN)?.addEventListener('click', () => exportHTML(lastReportData, currentPeriod));
|
|
585
|
-
document.getElementById(ID.DOWNLOAD_MD_BTN)?.addEventListener('click', () => {
|
|
586
|
-
const state = getWorkReportState();
|
|
587
|
-
downloadMarkdown(currentPeriod, currentDate);
|
|
588
|
-
});
|
|
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);
|
|
589
729
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
if (moonIcon) moonIcon.style.display = isDark ? 'none' : '';
|
|
599
|
-
if (sunIcon) sunIcon.style.display = isDark ? '' : 'none';
|
|
600
|
-
if (themeBtn) themeBtn.title = isDark ? '切换日间模式' : '切换暗色模式';
|
|
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);
|
|
601
738
|
}
|
|
602
739
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
document.
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
document.
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
if (appEl && appEl._x_dataStack) {
|
|
620
|
-
const app = appEl._x_dataStack[0];
|
|
621
|
-
if (app && app.loadCurrentView) app.loadCurrentView();
|
|
740
|
+
/* ── Settings Modal ── */
|
|
741
|
+
window.openSettings = async () => {
|
|
742
|
+
const modal = document.getElementById('settingsModal');
|
|
743
|
+
if (modal) modal.style.display = 'flex';
|
|
744
|
+
try {
|
|
745
|
+
const cfg = await fetchConfig();
|
|
746
|
+
const dirEl = document.getElementById('cfgClaudeDir');
|
|
747
|
+
const reposEl = document.getElementById('cfgRepos');
|
|
748
|
+
const excludeEl = document.getElementById('cfgExclude');
|
|
749
|
+
const kwEl = document.getElementById('cfgKeywords');
|
|
750
|
+
if (dirEl) dirEl.value = cfg.claudeDir || '';
|
|
751
|
+
if (reposEl) reposEl.value = (cfg.repos || []).join('\n');
|
|
752
|
+
if (excludeEl) excludeEl.value = (cfg.excludeProjects || []).join('\n');
|
|
753
|
+
if (kwEl) kwEl.value = cfg.scenarioKeywords ? JSON.stringify(cfg.scenarioKeywords, null, 2) : '{}';
|
|
754
|
+
} catch (err) {
|
|
755
|
+
showToast('加载配置失败: ' + err.message);
|
|
622
756
|
}
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
document.getElementById('welcomeStartBtn')?.addEventListener('click', async () => {
|
|
760
|
+
const claudeDir = document.getElementById('welcomeClaudeDir').value.trim();
|
|
761
|
+
const reposRaw = document.getElementById('welcomeRepos').value.trim();
|
|
762
|
+
const hint = document.getElementById('welcomeHint');
|
|
763
|
+
if (!claudeDir) { hint.textContent = '请输入 Claude 日志目录路径'; hint.style.color = 'var(--dest)'; return; }
|
|
764
|
+
const repos = reposRaw ? reposRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
765
|
+
try {
|
|
766
|
+
hint.textContent = '保存配置中...'; hint.style.color = 'var(--muted-foreground)';
|
|
767
|
+
await saveConfig({ claudeDir, repos });
|
|
768
|
+
hint.textContent = '配置已保存,加载数据中...';
|
|
769
|
+
window.location.reload();
|
|
770
|
+
} catch (err) { hint.textContent = '保存失败: ' + err.message; hint.style.color = 'var(--dest)'; }
|
|
623
771
|
});
|
|
624
772
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
773
|
+
window.saveSettings = async () => {
|
|
774
|
+
let scenarioKeywords;
|
|
775
|
+
try { scenarioKeywords = JSON.parse(document.getElementById('cfgKeywords').value); } catch { showToast('场景关键词 JSON 格式错误'); return; }
|
|
776
|
+
const payload = {
|
|
777
|
+
claudeDir: document.getElementById('cfgClaudeDir').value.trim(),
|
|
778
|
+
repos: document.getElementById('cfgRepos').value.split('\n').map(s => s.trim()).filter(Boolean),
|
|
779
|
+
excludeProjects: document.getElementById('cfgExclude').value.split('\n').map(s => s.trim()).filter(Boolean),
|
|
780
|
+
scenarioKeywords,
|
|
781
|
+
};
|
|
782
|
+
try {
|
|
783
|
+
await saveConfig(payload);
|
|
784
|
+
document.getElementById('settingsModal').style.display = 'none';
|
|
785
|
+
window.location.reload();
|
|
786
|
+
} catch (err) { showToast('保存失败: ' + err.message); }
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
/* ── Drill-down global handler ── */
|
|
790
|
+
window._drillHandler = async (type, key, label) => {
|
|
791
|
+
const modal = document.getElementById(ID.DRILL_MODAL);
|
|
792
|
+
const title = document.getElementById(ID.DRILL_TITLE);
|
|
793
|
+
const body = document.getElementById(ID.DRILL_BODY);
|
|
794
|
+
if (title) title.textContent = label + ' 匹配示例';
|
|
795
|
+
if (body) body.innerHTML = '<div class="drill-empty">加载中...</div>';
|
|
796
|
+
if (modal) modal.style.display = 'flex';
|
|
797
|
+
try {
|
|
798
|
+
const appEl = document.querySelector('[x-data]');
|
|
799
|
+
const app = appEl?._x_dataStack?.[0];
|
|
800
|
+
const period = app?.period || 'daily';
|
|
801
|
+
const date = app?.currentDate || new Date().toISOString().slice(0, 10);
|
|
802
|
+
const rows = await fetchDetails({ period, date, dimension: type, key });
|
|
803
|
+
if (!rows.length) { if (body) body.innerHTML = '<div class="drill-empty">无匹配记录</div>'; return; }
|
|
804
|
+
if (body) body.innerHTML = '<table class="drill-table"><tr><th>用户消息</th><th>时间</th></tr>' + rows.map(r => `<tr><td class="drill-text" title="${esc(r.text)}">${esc(r.text)}</td><td>${esc(r.timestamp?.slice(0, 16)?.replace('T', ' '))}</td></tr>`).join('') + '</table>';
|
|
805
|
+
} catch (e) {
|
|
806
|
+
console.warn('drillHandler failed:', e);
|
|
807
|
+
if (body) body.innerHTML = '<div class="drill-empty">加载失败</div>';
|
|
634
808
|
}
|
|
635
|
-
|
|
636
|
-
}
|
|
809
|
+
};
|