cc-insight 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/README.zh.md +106 -0
- package/bin/cc-insight.js +51 -0
- package/package.json +51 -0
- package/public/index.html +239 -0
- package/public/js/app.js +129 -0
- package/public/js/charts.js +0 -0
- package/public/js/heatmap.js +0 -0
- package/public/js/insights.js +326 -0
- package/public/js/mcp.js +143 -0
- package/public/js/overview.js +421 -0
- package/public/js/poster.js +1025 -0
- package/public/js/skills.js +637 -0
- package/public/js/theme.js +4 -0
- package/src/api.js +501 -0
- package/src/classifiers/topic-rules.js +100 -0
- package/src/config.js +28 -0
- package/src/db/db.js +39 -0
- package/src/db/queries.js +314 -0
- package/src/db/schema.js +46 -0
- package/src/indexer.js +158 -0
- package/src/parsers/jsonl.js +70 -0
- package/src/parsers/security.js +23 -0
- package/src/parsers/skill-md.js +38 -0
- package/src/poster.js +265 -0
- package/src/server.js +67 -0
- package/src/watcher.js +28 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// public/js/overview.js
|
|
2
|
+
import { setRange } from './app.js'
|
|
3
|
+
|
|
4
|
+
function fmtDuration(sec) {
|
|
5
|
+
if (!sec) return '0m'
|
|
6
|
+
const h = Math.floor(sec / 3600)
|
|
7
|
+
const m = Math.floor((sec % 3600) / 60)
|
|
8
|
+
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function rangeFilter(current) {
|
|
12
|
+
const ranges = [
|
|
13
|
+
{ value: '7d', label: '7 天' },
|
|
14
|
+
{ value: '30d', label: '30 天' },
|
|
15
|
+
{ value: '90d', label: '90 天' },
|
|
16
|
+
{ value: 'all', label: '全部' },
|
|
17
|
+
]
|
|
18
|
+
return `
|
|
19
|
+
<div class="range-filter">
|
|
20
|
+
<span>时间范围:</span>
|
|
21
|
+
${ranges.map(r => `
|
|
22
|
+
<button class="range-btn ${r.value === current ? 'active' : ''}"
|
|
23
|
+
data-range="${r.value}">${r.label}</button>
|
|
24
|
+
`).join('')}
|
|
25
|
+
</div>`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function statsCards(data) {
|
|
29
|
+
return `
|
|
30
|
+
<div class="grid-4" style="margin-bottom:14px;">
|
|
31
|
+
<div class="card">
|
|
32
|
+
<div class="card-label">Sessions</div>
|
|
33
|
+
<div class="card-value green">${data.sessions ?? 0}</div>
|
|
34
|
+
<div class="card-sub">次对话</div>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="card">
|
|
37
|
+
<div class="card-label">Duration</div>
|
|
38
|
+
<div class="card-value cyan">${fmtDuration(data.totalDurationSec)}</div>
|
|
39
|
+
<div class="card-sub">累计时长</div>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="card">
|
|
42
|
+
<div class="card-label">Peak Period</div>
|
|
43
|
+
<div class="card-value amber">${data.peakPeriod ?? '—'}</div>
|
|
44
|
+
<div class="card-sub">活跃时段</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="card">
|
|
47
|
+
<div class="card-label">Avg / Day</div>
|
|
48
|
+
<div class="card-value purple">${fmtDuration(data.avgDailyDurationSec)}</div>
|
|
49
|
+
<div class="card-sub">日均时长</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function renderOverview(container, range, preserveScroll = true) {
|
|
55
|
+
container.style.display = 'flex'
|
|
56
|
+
container.style.flexDirection = 'column'
|
|
57
|
+
const savedScroll = preserveScroll ? (container.querySelector('.split-right')?.scrollTop ?? 0) : 0
|
|
58
|
+
|
|
59
|
+
const [overview, heatmap, dist, insights, toolDist] = await Promise.all([
|
|
60
|
+
fetch(`/api/overview?range=${range}`).then(r => r.json()),
|
|
61
|
+
fetch(`/api/heatmap?range=${range}`).then(r => r.json()),
|
|
62
|
+
fetch(`/api/distribution?range=${range}`).then(r => r.json()),
|
|
63
|
+
fetch(`/api/insights?range=${range}`).then(r => r.json()),
|
|
64
|
+
fetch(`/api/tool-distribution?range=${range}`).then(r => r.json()),
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
container.innerHTML = `
|
|
68
|
+
${rangeFilter(range)}
|
|
69
|
+
${statsCards(overview)}
|
|
70
|
+
<div class="split">
|
|
71
|
+
<div class="split-left">
|
|
72
|
+
<div id="insights-panel"></div>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="split-right">
|
|
75
|
+
<div class="card" style="margin-bottom:10px;">
|
|
76
|
+
<div class="section-header">
|
|
77
|
+
<span class="section-title">Activity Heatmap</span>
|
|
78
|
+
</div>
|
|
79
|
+
<div id="heatmap-canvas"></div>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="card" style="margin-bottom:10px;">
|
|
82
|
+
<div class="section-header">
|
|
83
|
+
<span class="section-title">24H 时间分布</span>
|
|
84
|
+
<span id="dist-peak-label" class="muted" style="font-size:12px;"></span>
|
|
85
|
+
</div>
|
|
86
|
+
<div id="dist-canvas"></div>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="card" style="display:flex;flex-direction:column;margin-bottom:10px;">
|
|
89
|
+
<div class="section-header" style="margin-bottom:8px;">
|
|
90
|
+
<span class="section-title">工具调用分布</span>
|
|
91
|
+
</div>
|
|
92
|
+
<div id="tool-dist-canvas" style="flex:1;display:flex;align-items:center;"></div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>`
|
|
96
|
+
|
|
97
|
+
container.querySelectorAll('.range-btn').forEach(btn => {
|
|
98
|
+
btn.addEventListener('click', () => setRange(btn.dataset.range))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
renderInsights(document.getElementById('insights-panel'), insights)
|
|
102
|
+
renderHeatmap(document.getElementById('heatmap-canvas'), heatmap)
|
|
103
|
+
renderDist(document.getElementById('dist-canvas'), dist)
|
|
104
|
+
renderToolDist(document.getElementById('tool-dist-canvas'), toolDist)
|
|
105
|
+
|
|
106
|
+
if (savedScroll > 0) {
|
|
107
|
+
const splitRight = container.querySelector('.split-right')
|
|
108
|
+
if (splitRight) splitRight.scrollTop = savedScroll
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Heatmap ──
|
|
113
|
+
function renderHeatmap(el, data) {
|
|
114
|
+
if (!data || data.length === 0) {
|
|
115
|
+
el.innerHTML = `<div style="color:var(--muted);font-size:14px;padding:8px 0;">暂无数据</div>`
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const map = Object.fromEntries(data.map(r => [r.day, r.count]))
|
|
120
|
+
const max = Math.max(...Object.values(map), 1)
|
|
121
|
+
|
|
122
|
+
const CELL = 12
|
|
123
|
+
const GAP = 3
|
|
124
|
+
const LABEL_W = 28
|
|
125
|
+
|
|
126
|
+
function intensity(count) {
|
|
127
|
+
if (count === 0) return 'var(--bg3)'
|
|
128
|
+
const pct = count / max
|
|
129
|
+
if (pct < 0.25) return '#0e4429'
|
|
130
|
+
if (pct < 0.5) return '#006d32'
|
|
131
|
+
if (pct < 0.75) return '#26a641'
|
|
132
|
+
return '#39d353'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const dayLabels = ['', 'Mon', '', 'Wed', '', 'Fri', '']
|
|
136
|
+
|
|
137
|
+
// 从最早数据对齐到周一,生成所有周
|
|
138
|
+
const today = new Date()
|
|
139
|
+
const start = new Date(data[0].day)
|
|
140
|
+
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
|
|
141
|
+
|
|
142
|
+
const weeks = []
|
|
143
|
+
let cur = new Date(start)
|
|
144
|
+
while (cur <= today) {
|
|
145
|
+
const week = []
|
|
146
|
+
for (let d = 0; d < 7; d++) {
|
|
147
|
+
const key = cur.toISOString().slice(0, 10)
|
|
148
|
+
week.push({ day: key, count: map[key] ?? 0 })
|
|
149
|
+
cur.setDate(cur.getDate() + 1)
|
|
150
|
+
}
|
|
151
|
+
weeks.push(week)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function weekCol(week, isPlaceholder = false) {
|
|
155
|
+
return `
|
|
156
|
+
<div style="display:flex;flex-direction:column;gap:${GAP}px;flex-shrink:0;">
|
|
157
|
+
${week.map(cell => `
|
|
158
|
+
<div ${isPlaceholder ? '' : `title="${cell.day}: ${cell.count} sessions"`}
|
|
159
|
+
style="width:${CELL}px;height:${CELL}px;border-radius:2px;
|
|
160
|
+
background:${isPlaceholder ? 'var(--bg3)' : intensity(cell.count)};
|
|
161
|
+
cursor:default;"></div>`).join('')}
|
|
162
|
+
</div>`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const emptyWeek = Array(7).fill({ day: '', count: 0 })
|
|
166
|
+
|
|
167
|
+
function render(containerW) {
|
|
168
|
+
const fittable = Math.floor((containerW - LABEL_W) / (CELL + GAP))
|
|
169
|
+
const padCount = Math.max(0, fittable - weeks.length)
|
|
170
|
+
const padHtml = Array(padCount).fill(null).map(() => weekCol(emptyWeek, true)).join('')
|
|
171
|
+
const dataHtml = weeks.map(w => weekCol(w)).join('')
|
|
172
|
+
|
|
173
|
+
el.innerHTML = `
|
|
174
|
+
<div style="display:flex;gap:${GAP}px;align-items:flex-start;">
|
|
175
|
+
<div style="display:flex;flex-direction:column;gap:${GAP}px;
|
|
176
|
+
width:${LABEL_W}px;flex-shrink:0;padding-top:2px;">
|
|
177
|
+
${dayLabels.map(l => `<div style="height:${CELL}px;font-size:10px;
|
|
178
|
+
color:var(--muted);line-height:${CELL}px;">${l}</div>`).join('')}
|
|
179
|
+
</div>
|
|
180
|
+
<div id="heatmap-scroll" style="overflow-x:auto;flex:1;
|
|
181
|
+
scrollbar-width:thin;scrollbar-color:var(--bg3) transparent;">
|
|
182
|
+
<style>#heatmap-scroll::-webkit-scrollbar{height:3px}
|
|
183
|
+
#heatmap-scroll::-webkit-scrollbar-thumb{background:var(--bg3);border-radius:2px}
|
|
184
|
+
</style>
|
|
185
|
+
<div style="display:flex;gap:${GAP}px;">${padHtml}${dataHtml}</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
<div style="display:flex;gap:4px;align-items:center;margin-top:8px;">
|
|
189
|
+
<span style="font-size:11px;color:var(--muted);">少</span>
|
|
190
|
+
${['var(--bg3)','#0e4429','#006d32','#26a641','#39d353'].map(c =>
|
|
191
|
+
`<div style="width:10px;height:10px;background:${c};border-radius:2px;"></div>`).join('')}
|
|
192
|
+
<span style="font-size:11px;color:var(--muted);">多</span>
|
|
193
|
+
</div>`
|
|
194
|
+
|
|
195
|
+
const scrollEl = el.querySelector('#heatmap-scroll')
|
|
196
|
+
if (scrollEl) scrollEl.scrollLeft = scrollEl.scrollWidth
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
render(el.clientWidth || 600)
|
|
200
|
+
|
|
201
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
202
|
+
const ro = new ResizeObserver(entries => {
|
|
203
|
+
const w = entries[0].contentRect.width
|
|
204
|
+
if (w > 10 && Math.abs(w - (el._lastW ?? 0)) > 4) { el._lastW = w; render(w) }
|
|
205
|
+
})
|
|
206
|
+
ro.observe(el)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── 24H Distribution ──
|
|
211
|
+
function renderDist(el, data) {
|
|
212
|
+
const map = Object.fromEntries(data.map(r => [r.hour, r.count]))
|
|
213
|
+
const hours = Array.from({ length: 24 }, (_, i) => ({
|
|
214
|
+
hour: String(i).padStart(2, '0'),
|
|
215
|
+
count: map[String(i).padStart(2, '0')] ?? 0,
|
|
216
|
+
}))
|
|
217
|
+
const max = Math.max(...hours.map(h => h.count), 1)
|
|
218
|
+
const peaks = new Set(max > 0 ? hours.filter(h => h.count === max).map(h => h.hour) : [])
|
|
219
|
+
const silent = hours.filter(h => h.count === 0)
|
|
220
|
+
|
|
221
|
+
const label = document.getElementById('dist-peak-label')
|
|
222
|
+
if (label && peaks.size > 0) {
|
|
223
|
+
const peakList = [...peaks].map(h => `${h}:00`).join('、')
|
|
224
|
+
label.textContent = `峰值 ${peakList} · 静默 ${silent.length}h`
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
el.style.position = 'relative'
|
|
228
|
+
el.innerHTML = `
|
|
229
|
+
<div class="dist-chart" style="display:flex;gap:2px;align-items:flex-end;height:60px;cursor:default;">
|
|
230
|
+
${hours.map(h => {
|
|
231
|
+
const pct = Math.max(h.count / max * 100, h.count > 0 ? 4 : 1)
|
|
232
|
+
const isPeak = peaks.has(h.hour) && max > 0
|
|
233
|
+
const color = isPeak ? 'var(--amber)' : (h.count > 0 ? 'var(--green)' : 'var(--bg3)')
|
|
234
|
+
return `<div class="dist-col" data-label="${h.hour}:00 — ${h.count} sessions"
|
|
235
|
+
style="flex:1;height:100%;display:flex;align-items:flex-end;">
|
|
236
|
+
<div style="width:100%;background:${color};border-radius:2px 2px 0 0;
|
|
237
|
+
height:${pct}%;min-height:2px;pointer-events:none;"></div>
|
|
238
|
+
</div>`
|
|
239
|
+
}).join('')}
|
|
240
|
+
</div>
|
|
241
|
+
<div style="display:flex;justify-content:space-between;margin-top:4px;">
|
|
242
|
+
${['0h','6h','12h','18h','23h'].map(l =>
|
|
243
|
+
`<span style="font-size:11px;color:var(--muted);">${l}</span>`).join('')}
|
|
244
|
+
</div>
|
|
245
|
+
<div id="dist-tip" style="position:fixed;pointer-events:none;opacity:0;
|
|
246
|
+
background:var(--bg2);border:1px solid var(--border);border-radius:4px;
|
|
247
|
+
padding:3px 8px;font-size:11px;color:var(--text);white-space:nowrap;z-index:50;
|
|
248
|
+
transition:opacity 0.1s;"></div>`
|
|
249
|
+
|
|
250
|
+
const tip = el.querySelector('#dist-tip')
|
|
251
|
+
el.querySelectorAll('.dist-col').forEach(col => {
|
|
252
|
+
col.addEventListener('mouseenter', () => { tip.textContent = col.dataset.label; tip.style.opacity = '1' })
|
|
253
|
+
col.addEventListener('mouseleave', () => { tip.style.opacity = '0' })
|
|
254
|
+
col.addEventListener('mousemove', e => {
|
|
255
|
+
tip.style.left = (e.clientX + 12) + 'px'
|
|
256
|
+
tip.style.top = (e.clientY - 24) + 'px'
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Insights ──
|
|
262
|
+
const INSIGHT_CONFIG = {
|
|
263
|
+
best_day: (d) => ({
|
|
264
|
+
icon: '🔥', color: 'var(--green)', title: '最高产',
|
|
265
|
+
body: `${d.day} 完成了 <span class="green">${d.count} 个 session</span>`
|
|
266
|
+
}),
|
|
267
|
+
silent_days: (d) => ({
|
|
268
|
+
icon: '😴', color: 'var(--red)', title: '静默期',
|
|
269
|
+
body: `已连续 <span class="red">${d.days} 天</span> 未使用 Claude Code`
|
|
270
|
+
}),
|
|
271
|
+
habit: (d) => ({
|
|
272
|
+
icon: d.label === '夜猫子' ? '🌙' : d.label === '早鸟' ? '🌅' : '💼',
|
|
273
|
+
color: 'var(--amber)', title: `你是${d.label}`,
|
|
274
|
+
body: `<span class="amber">${d.pct}%</span> 的 session 发生在对应时段`
|
|
275
|
+
}),
|
|
276
|
+
avg_daily: (d) => {
|
|
277
|
+
const sec = d.avgSec ?? 0
|
|
278
|
+
const h = Math.floor(sec / 3600)
|
|
279
|
+
const m = Math.floor((sec % 3600) / 60)
|
|
280
|
+
const label = h > 0 ? `${h}h ${m}m` : `${m}m`
|
|
281
|
+
return { icon: '⏱️', color: 'var(--purple)', title: '日均时长',
|
|
282
|
+
body: `平均每天使用 <span class="purple">${label}</span>` }
|
|
283
|
+
},
|
|
284
|
+
trend: (d) => ({
|
|
285
|
+
icon: d.change > 0 ? '📈' : '📉',
|
|
286
|
+
color: d.change > 0 ? 'var(--cyan)' : 'var(--red)',
|
|
287
|
+
title: '使用趋势',
|
|
288
|
+
body: d.change > 0
|
|
289
|
+
? `比上个同期增长 <span class="cyan">+${d.change}%</span>`
|
|
290
|
+
: `比上个同期下降 <span class="red">${d.change}%</span>`
|
|
291
|
+
}),
|
|
292
|
+
topic_dominant: (d) => ({
|
|
293
|
+
icon: '🧠',
|
|
294
|
+
color: 'var(--purple)',
|
|
295
|
+
title: '话题占比',
|
|
296
|
+
body: d.ratio
|
|
297
|
+
? `<span class="purple">${d.topic}</span> 占 ${d.pct}%,是第二名的 ${d.ratio}×`
|
|
298
|
+
: `<span class="purple">${d.topic}</span> 占 ${d.pct}%`
|
|
299
|
+
}),
|
|
300
|
+
topic_keyword: (d) => ({
|
|
301
|
+
icon: '🔑',
|
|
302
|
+
color: 'var(--cyan)',
|
|
303
|
+
title: '高频词',
|
|
304
|
+
body: `<span class="cyan">${d.word}</span> 在 ${d.count} 个 session 中出现`
|
|
305
|
+
}),
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function renderInsights(el, insights) {
|
|
309
|
+
if (!el) return
|
|
310
|
+
|
|
311
|
+
if (!insights || insights.length === 0) {
|
|
312
|
+
el.innerHTML = `
|
|
313
|
+
<div class="card">
|
|
314
|
+
<div class="section-title" style="margin-bottom:8px;">Insights</div>
|
|
315
|
+
<div class="muted" style="font-size:14px;">数据积累中,稍后会自动生成洞察</div>
|
|
316
|
+
</div>`
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const cards = insights.map(item => {
|
|
321
|
+
const cfg = INSIGHT_CONFIG[item.type]?.(item)
|
|
322
|
+
if (!cfg) return ''
|
|
323
|
+
return `
|
|
324
|
+
<div style="background:var(--bg2);border:1px solid var(--border);
|
|
325
|
+
border-left:3px solid ${cfg.color};border-radius:var(--radius);
|
|
326
|
+
padding:10px 12px;display:flex;gap:10px;align-items:flex-start;">
|
|
327
|
+
<span style="font-size:18px;line-height:1;">${cfg.icon}</span>
|
|
328
|
+
<div>
|
|
329
|
+
<div style="color:var(--text);font-size:14px;margin-bottom:3px;">${cfg.title}</div>
|
|
330
|
+
<div style="color:var(--muted);font-size:14px;">${cfg.body}</div>
|
|
331
|
+
</div>
|
|
332
|
+
</div>`
|
|
333
|
+
}).join('')
|
|
334
|
+
|
|
335
|
+
el.innerHTML = `
|
|
336
|
+
<div class="card">
|
|
337
|
+
<div class="section-title" style="margin-bottom:10px;">Insights</div>
|
|
338
|
+
<div style="display:flex;flex-direction:column;gap:8px;">${cards}</div>
|
|
339
|
+
</div>`
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── 工具调用分布环形图 ──
|
|
343
|
+
function renderToolDist(el, data) {
|
|
344
|
+
if (!data || data.length === 0) {
|
|
345
|
+
el.innerHTML = `<div style="color:var(--muted);font-size:14px;padding:12px 0;">暂无数据</div>`
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const COLORS = [
|
|
350
|
+
'var(--green)', 'var(--cyan)', 'var(--amber)', 'var(--purple)', 'var(--red)',
|
|
351
|
+
'#f97316', '#06b6d4', '#a3e635', '#e879f9', '#38bdf8',
|
|
352
|
+
]
|
|
353
|
+
const TOP_N = 8
|
|
354
|
+
|
|
355
|
+
const total = data.reduce((s, r) => s + r.count, 0)
|
|
356
|
+
const top = data.slice(0, TOP_N)
|
|
357
|
+
const otherCount = data.slice(TOP_N).reduce((s, r) => s + r.count, 0)
|
|
358
|
+
const items = otherCount > 0 ? [...top, { toolName: '其他', count: otherCount }] : top
|
|
359
|
+
|
|
360
|
+
// SVG 环形图
|
|
361
|
+
const R = 54, r = 32, cx = 70, cy = 70
|
|
362
|
+
let angle = -Math.PI / 2
|
|
363
|
+
const paths = items.map((item, i) => {
|
|
364
|
+
const pct = item.count / total
|
|
365
|
+
const sweep = pct * 2 * Math.PI
|
|
366
|
+
const x1 = cx + R * Math.cos(angle), y1 = cy + R * Math.sin(angle)
|
|
367
|
+
const x2 = cx + R * Math.cos(angle + sweep), y2 = cy + R * Math.sin(angle + sweep)
|
|
368
|
+
const ix1 = cx + r * Math.cos(angle), iy1 = cy + r * Math.sin(angle)
|
|
369
|
+
const ix2 = cx + r * Math.cos(angle + sweep), iy2 = cy + r * Math.sin(angle + sweep)
|
|
370
|
+
const large = sweep > Math.PI ? 1 : 0
|
|
371
|
+
const color = i < COLORS.length ? COLORS[i] : 'var(--muted)'
|
|
372
|
+
const d = `M${x1},${y1} A${R},${R},0,${large},1,${x2},${y2} L${ix2},${iy2} A${r},${r},0,${large},0,${ix1},${iy1} Z`
|
|
373
|
+
angle += sweep
|
|
374
|
+
return `<path d="${d}" fill="${color}" opacity="0.9"/>`
|
|
375
|
+
}).join('')
|
|
376
|
+
|
|
377
|
+
const totalFmt = total >= 10000 ? (total / 10000).toFixed(1) + 'w次' : total.toLocaleString() + '次'
|
|
378
|
+
|
|
379
|
+
function legendItem(item, i) {
|
|
380
|
+
const color = i < COLORS.length ? COLORS[i] : 'var(--muted)'
|
|
381
|
+
const pct = Math.round(item.count / total * 100)
|
|
382
|
+
const countFmt = item.count >= 10000 ? (item.count / 10000).toFixed(1) + 'w次' : item.count.toLocaleString() + '次'
|
|
383
|
+
return `
|
|
384
|
+
<div style="display:flex;align-items:center;gap:6px;min-width:0;">
|
|
385
|
+
<span style="width:10px;height:10px;border-radius:2px;background:${color};flex-shrink:0;"></span>
|
|
386
|
+
<span style="font-size:13px;color:var(--text);min-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${item.toolName}</span>
|
|
387
|
+
<span style="font-size:13px;color:var(--muted);white-space:nowrap;min-width:32px;text-align:right;">${pct}%</span>
|
|
388
|
+
<span style="font-size:13px;color:var(--muted);white-space:nowrap;min-width:58px;text-align:right;">${countFmt}</span>
|
|
389
|
+
</div>`
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 超过 5 个时拆成 2 列(每列最多 5 个)
|
|
393
|
+
const useTwoCols = items.length > 5
|
|
394
|
+
let legendHtml
|
|
395
|
+
if (useTwoCols) {
|
|
396
|
+
const col1 = items.slice(0, 5)
|
|
397
|
+
const col2 = items.slice(5, 10)
|
|
398
|
+
legendHtml = `
|
|
399
|
+
<div style="display:flex;gap:16px;">
|
|
400
|
+
<div style="display:flex;flex-direction:column;gap:6px;">${col1.map((item, i) => legendItem(item, i)).join('')}</div>
|
|
401
|
+
<div style="display:flex;flex-direction:column;gap:6px;">${col2.map((item, i) => legendItem(item, i + 5)).join('')}</div>
|
|
402
|
+
</div>`
|
|
403
|
+
} else {
|
|
404
|
+
legendHtml = `<div style="display:flex;flex-direction:column;gap:6px;">${items.map(legendItem).join('')}</div>`
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
el.innerHTML = `
|
|
408
|
+
<div style="display:flex;gap:20px;align-items:center;justify-content:center;width:100%;">
|
|
409
|
+
<div style="flex-shrink:0;display:flex;align-items:center;justify-content:center;">
|
|
410
|
+
<svg width="120" height="120" viewBox="0 0 140 140">
|
|
411
|
+
${paths}
|
|
412
|
+
<text x="${cx}" y="${cy - 6}" text-anchor="middle"
|
|
413
|
+
style="font-size:11px;fill:var(--muted);font-family:var(--font);">总计</text>
|
|
414
|
+
<text x="${cx}" y="${cy + 10}" text-anchor="middle"
|
|
415
|
+
style="font-size:13px;font-weight:bold;fill:var(--text);font-family:var(--font);">${totalFmt}</text>
|
|
416
|
+
</svg>
|
|
417
|
+
</div>
|
|
418
|
+
${legendHtml}
|
|
419
|
+
</div>`
|
|
420
|
+
}
|
|
421
|
+
|