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,1025 @@
|
|
|
1
|
+
// public/js/poster.js — 海报弹窗
|
|
2
|
+
|
|
3
|
+
// ── 样式(只注入一次)──────────────────────────────────────────
|
|
4
|
+
const POSTER_CSS = `
|
|
5
|
+
.poster-overlay {
|
|
6
|
+
position: fixed; inset: 0; z-index: 300;
|
|
7
|
+
background: rgba(0,0,0,0.75);
|
|
8
|
+
display: flex; align-items: center; justify-content: center;
|
|
9
|
+
padding: 20px;
|
|
10
|
+
animation: poster-fade-in 0.15s ease;
|
|
11
|
+
}
|
|
12
|
+
@keyframes poster-fade-in { from { opacity: 0 } to { opacity: 1 } }
|
|
13
|
+
|
|
14
|
+
.poster-dialog {
|
|
15
|
+
display: flex; gap: 0;
|
|
16
|
+
background: var(--bg2); border: 1px solid var(--border);
|
|
17
|
+
border-radius: 10px; overflow: hidden;
|
|
18
|
+
width: min(920px, 100%); max-height: calc(100vh - 40px);
|
|
19
|
+
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* ── 左侧:海报预览 ── */
|
|
23
|
+
.poster-preview-panel {
|
|
24
|
+
display: flex; flex-direction: column;
|
|
25
|
+
align-items: center; justify-content: center;
|
|
26
|
+
background: var(--bg); border-right: 1px solid var(--border);
|
|
27
|
+
padding: 24px; flex-shrink: 0; width: 340px;
|
|
28
|
+
}
|
|
29
|
+
.poster-preview-label {
|
|
30
|
+
font-size: 11px; color: var(--muted); letter-spacing: 1px;
|
|
31
|
+
text-transform: uppercase; margin-bottom: 14px; align-self: flex-start;
|
|
32
|
+
}
|
|
33
|
+
/* 海报容器:宽度固定,高度由内容决定 */
|
|
34
|
+
.poster-canvas-wrap {
|
|
35
|
+
width: 292px; height: auto;
|
|
36
|
+
border-radius: 8px; overflow: hidden;
|
|
37
|
+
position: relative;
|
|
38
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
39
|
+
}
|
|
40
|
+
/* 数据说明 */
|
|
41
|
+
.poster-range-note {
|
|
42
|
+
margin-top: 12px; font-size: 11px; color: var(--muted);
|
|
43
|
+
text-align: center; line-height: 1.6;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ── 右侧:编辑面板 ── */
|
|
47
|
+
.poster-edit-panel {
|
|
48
|
+
flex: 1; display: flex; flex-direction: column;
|
|
49
|
+
min-width: 0; max-height: calc(100vh - 40px); overflow: hidden;
|
|
50
|
+
}
|
|
51
|
+
.poster-edit-header {
|
|
52
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
53
|
+
padding: 16px 20px 14px; border-bottom: 1px solid var(--border);
|
|
54
|
+
flex-shrink: 0;
|
|
55
|
+
}
|
|
56
|
+
.poster-edit-title {
|
|
57
|
+
font-size: 14px; font-weight: 600; color: var(--text);
|
|
58
|
+
}
|
|
59
|
+
.poster-close-btn {
|
|
60
|
+
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
|
|
61
|
+
border: none; background: transparent; cursor: pointer;
|
|
62
|
+
color: var(--muted); border-radius: 4px; transition: all 0.15s;
|
|
63
|
+
}
|
|
64
|
+
.poster-close-btn:hover { background: var(--bg3); color: var(--text); }
|
|
65
|
+
|
|
66
|
+
.poster-edit-body {
|
|
67
|
+
flex: 1; overflow-y: auto; padding: 20px;
|
|
68
|
+
display: flex; flex-direction: column; gap: 20px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ── 编辑区域各 section ── */
|
|
72
|
+
.poster-section { display: flex; flex-direction: column; gap: 8px; }
|
|
73
|
+
.poster-section-header {
|
|
74
|
+
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
75
|
+
}
|
|
76
|
+
.poster-section-title {
|
|
77
|
+
font-size: 11px; color: var(--muted);
|
|
78
|
+
letter-spacing: 1px; text-transform: uppercase; flex-shrink: 0;
|
|
79
|
+
}
|
|
80
|
+
.poster-section-hint { font-size: 11px; color: var(--muted); text-align: right; }
|
|
81
|
+
.poster-input {
|
|
82
|
+
background: var(--bg); border: 1px solid var(--border);
|
|
83
|
+
border-radius: var(--radius); padding: 8px 10px;
|
|
84
|
+
color: var(--text); font-size: 14px; font-family: var(--font);
|
|
85
|
+
width: 100%; outline: none; transition: border-color 0.15s;
|
|
86
|
+
}
|
|
87
|
+
.poster-input:focus { border-color: var(--green); }
|
|
88
|
+
.poster-input-hint { font-size: 12px; color: var(--muted); }
|
|
89
|
+
|
|
90
|
+
/* ── 底部操作按钮 ── */
|
|
91
|
+
.poster-edit-footer {
|
|
92
|
+
display: flex; gap: 10px; padding: 14px 20px;
|
|
93
|
+
border-top: 1px solid var(--border); flex-shrink: 0;
|
|
94
|
+
}
|
|
95
|
+
.poster-action-btn {
|
|
96
|
+
flex: 1; padding: 8px 0; border-radius: var(--radius);
|
|
97
|
+
font-size: 14px; font-family: var(--font);
|
|
98
|
+
cursor: pointer; border: 1px solid var(--border);
|
|
99
|
+
background: transparent; color: var(--muted); transition: all 0.15s;
|
|
100
|
+
}
|
|
101
|
+
.poster-action-btn:hover {
|
|
102
|
+
color: var(--text); border-color: var(--text);
|
|
103
|
+
}
|
|
104
|
+
.poster-action-btn.primary {
|
|
105
|
+
background: color-mix(in srgb, var(--green) 15%, transparent);
|
|
106
|
+
border-color: var(--green); color: var(--green);
|
|
107
|
+
}
|
|
108
|
+
.poster-action-btn.primary:hover {
|
|
109
|
+
background: color-mix(in srgb, var(--green) 25%, transparent);
|
|
110
|
+
}
|
|
111
|
+
.poster-action-btn:disabled {
|
|
112
|
+
opacity: 0.4; cursor: not-allowed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ── loading ── */
|
|
116
|
+
.poster-loading {
|
|
117
|
+
display: flex; flex-direction: column;
|
|
118
|
+
align-items: center; justify-content: center;
|
|
119
|
+
height: 100%; gap: 12px; color: var(--muted); font-size: 13px;
|
|
120
|
+
}
|
|
121
|
+
.poster-spinner {
|
|
122
|
+
width: 20px; height: 20px; border: 2px solid var(--border);
|
|
123
|
+
border-top-color: var(--green); border-radius: 50%;
|
|
124
|
+
animation: poster-spin 0.7s linear infinite;
|
|
125
|
+
}
|
|
126
|
+
@keyframes poster-spin { to { transform: rotate(360deg) } }
|
|
127
|
+
`
|
|
128
|
+
|
|
129
|
+
let _cssInjected = false
|
|
130
|
+
function injectCSS() {
|
|
131
|
+
if (_cssInjected) return
|
|
132
|
+
const style = document.createElement('style')
|
|
133
|
+
style.textContent = POSTER_CSS
|
|
134
|
+
document.head.appendChild(style)
|
|
135
|
+
_cssInjected = true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── 弹窗状态 ──────────────────────────────────────────────────
|
|
139
|
+
let _overlay = null // 当前弹窗 DOM
|
|
140
|
+
let _data = null // 当前海报数据缓存
|
|
141
|
+
|
|
142
|
+
// ── 公开入口 ─────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 打开海报弹窗。
|
|
146
|
+
* @param {string} range - 当前时间范围
|
|
147
|
+
*/
|
|
148
|
+
export async function openPosterModal(range) {
|
|
149
|
+
if (_overlay) return // 已打开,防重复
|
|
150
|
+
injectCSS()
|
|
151
|
+
|
|
152
|
+
_overlay = buildSkeleton(range)
|
|
153
|
+
document.body.appendChild(_overlay)
|
|
154
|
+
|
|
155
|
+
// 点击遮罩关闭
|
|
156
|
+
_overlay.addEventListener('click', e => {
|
|
157
|
+
if (e.target === _overlay) closePosterModal()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// 导出按钮
|
|
161
|
+
document.getElementById('poster-copy-btn')?.addEventListener('click', () => exportPoster('copy'))
|
|
162
|
+
document.getElementById('poster-download-btn')?.addEventListener('click', () => exportPoster('download'))
|
|
163
|
+
|
|
164
|
+
// 加载数据
|
|
165
|
+
await loadPosterData(range)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function closePosterModal() {
|
|
169
|
+
_overlay?.remove()
|
|
170
|
+
_overlay = null
|
|
171
|
+
_data = null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── 构建弹窗骨架 DOM ──────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
function buildSkeleton(range) {
|
|
177
|
+
const overlay = document.createElement('div')
|
|
178
|
+
overlay.className = 'poster-overlay'
|
|
179
|
+
overlay.innerHTML = `
|
|
180
|
+
<div class="poster-dialog">
|
|
181
|
+
|
|
182
|
+
<!-- 左侧:海报预览 -->
|
|
183
|
+
<div class="poster-preview-panel">
|
|
184
|
+
<div class="poster-preview-label">海报预览</div>
|
|
185
|
+
<div class="poster-canvas-wrap" id="poster-canvas-wrap">
|
|
186
|
+
<div class="poster-loading" id="poster-preview-loading">
|
|
187
|
+
<div class="poster-spinner"></div>
|
|
188
|
+
<span>正在生成…</span>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<!-- 右侧:编辑面板 -->
|
|
194
|
+
<div class="poster-edit-panel">
|
|
195
|
+
<div class="poster-edit-header">
|
|
196
|
+
<span class="poster-edit-title">自定义海报</span>
|
|
197
|
+
<button class="poster-close-btn" id="poster-close-btn" title="关闭">
|
|
198
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
|
199
|
+
stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
|
200
|
+
<path d="M2 2l10 10M12 2L2 12"/>
|
|
201
|
+
</svg>
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div class="poster-edit-body" id="poster-edit-body">
|
|
206
|
+
<div class="poster-loading">
|
|
207
|
+
<div class="poster-spinner"></div>
|
|
208
|
+
<span>加载数据中…</span>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div class="poster-edit-footer">
|
|
213
|
+
<button class="poster-action-btn" id="poster-copy-btn" disabled>复制图片</button>
|
|
214
|
+
<button class="poster-action-btn primary" id="poster-download-btn" disabled>下载保存</button>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
</div>
|
|
219
|
+
`
|
|
220
|
+
|
|
221
|
+
overlay.querySelector('#poster-close-btn').addEventListener('click', closePosterModal)
|
|
222
|
+
|
|
223
|
+
return overlay
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── 加载数据并渲染 ────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
async function loadPosterData(range) {
|
|
229
|
+
let data
|
|
230
|
+
try {
|
|
231
|
+
const res = await fetch(`/api/poster/data?range=${range}`)
|
|
232
|
+
data = await res.json()
|
|
233
|
+
} catch (e) {
|
|
234
|
+
console.error('[poster] fetch error', e)
|
|
235
|
+
document.getElementById('poster-edit-body').innerHTML =
|
|
236
|
+
`<div style="color:var(--red);font-size:13px;padding:20px;">数据加载失败:${e.message}</div>`
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
_data = data
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
renderPosterContent(data)
|
|
244
|
+
} catch (e) {
|
|
245
|
+
console.error('[poster] render error', e)
|
|
246
|
+
document.getElementById('poster-edit-body').innerHTML =
|
|
247
|
+
`<div style="color:var(--red);font-size:13px;padding:20px;">
|
|
248
|
+
渲染失败:${e.message}<br>
|
|
249
|
+
<pre style="font-size:11px;margin-top:8px;color:var(--muted);white-space:pre-wrap;">${e.stack?.split('\n').slice(0,4).join('\n')}</pre>
|
|
250
|
+
</div>`
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── 渲染内容 ──────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function renderPosterContent(data) {
|
|
257
|
+
// P6:渲染海报视觉
|
|
258
|
+
const wrap = document.getElementById('poster-canvas-wrap')
|
|
259
|
+
if (wrap) {
|
|
260
|
+
wrap.innerHTML = ''
|
|
261
|
+
wrap.appendChild(buildPosterCard(data))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// P7:渲染编辑表单
|
|
265
|
+
renderEditPanel(data)
|
|
266
|
+
|
|
267
|
+
// 激活操作按钮
|
|
268
|
+
document.getElementById('poster-copy-btn')?.removeAttribute('disabled')
|
|
269
|
+
document.getElementById('poster-download-btn')?.removeAttribute('disabled')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── P6:海报视觉 ──────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
// 海报虚拟尺寸(导出用,预览通过 scale 缩放)
|
|
275
|
+
const POSTER_W = 540
|
|
276
|
+
const POSTER_H = 720
|
|
277
|
+
const PREVIEW_W = 292 // 必须与 CSS .poster-canvas-wrap 宽度一致
|
|
278
|
+
|
|
279
|
+
// 颜色常量(硬编码确保导出时颜色稳定)
|
|
280
|
+
const C = {
|
|
281
|
+
bg: '#0d1117',
|
|
282
|
+
bg2: '#111827',
|
|
283
|
+
bg3: '#1f2937',
|
|
284
|
+
border: '#1f2937',
|
|
285
|
+
text: '#e5e7eb',
|
|
286
|
+
muted: '#6b7280',
|
|
287
|
+
green: '#4ade80',
|
|
288
|
+
cyan: '#22d3ee',
|
|
289
|
+
amber: '#f59e0b',
|
|
290
|
+
purple: '#a78bfa',
|
|
291
|
+
font: "'JetBrains Mono','Fira Code','Cascadia Code',monospace",
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function buildPosterCard(data) {
|
|
295
|
+
const scale = PREVIEW_W / POSTER_W
|
|
296
|
+
const m = data.metrics ?? {}
|
|
297
|
+
const rangeLabel = { '7d':'近 7 天','30d':'近 30 天','90d':'近 90 天','all':'全部' }
|
|
298
|
+
|
|
299
|
+
// 预先计算有无内容,用于条件渲染分隔线
|
|
300
|
+
const ALL_METRIC_DEFS_TEMP = ['sessions','avgDailyDuration','peak','topSkill','totalDuration','silentDays']
|
|
301
|
+
const selectedKeys0 = m._selectedKeys ?? ['sessions','avgDailyDuration','peak','topSkill']
|
|
302
|
+
const hasMetrics = selectedKeys0.some(k => ALL_METRIC_DEFS_TEMP.includes(k))
|
|
303
|
+
const hasCharts = (data.heatmap ?? []).length > 0 || (data.distribution ?? []).length > 0
|
|
304
|
+
|
|
305
|
+
// 外层容器:宽度固定,高度跟随内容
|
|
306
|
+
const wrap = el('div', {
|
|
307
|
+
style: ss({ width: PREVIEW_W+'px', overflow: 'hidden',
|
|
308
|
+
borderRadius: '6px', position: 'relative' }),
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// 海报卡片:实际 540×720,缩放显示
|
|
312
|
+
const card = el('div', {
|
|
313
|
+
id: 'poster-card',
|
|
314
|
+
style: ss({
|
|
315
|
+
width: POSTER_W+'px',
|
|
316
|
+
transformOrigin: 'top left',
|
|
317
|
+
transform: `scale(${scale})`,
|
|
318
|
+
background: C.bg,
|
|
319
|
+
fontFamily: C.font,
|
|
320
|
+
position: 'relative',
|
|
321
|
+
boxSizing: 'border-box',
|
|
322
|
+
padding: '32px',
|
|
323
|
+
display: 'flex', flexDirection: 'column',
|
|
324
|
+
}),
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// 顶部光晕装饰
|
|
328
|
+
card.appendChild(el('div', { style: ss({
|
|
329
|
+
position: 'absolute', top: '-80px', left: '50%',
|
|
330
|
+
transform: 'translateX(-50%)',
|
|
331
|
+
width: '340px', height: '200px',
|
|
332
|
+
background: 'radial-gradient(ellipse at center, rgba(74,222,128,0.12) 0%, transparent 70%)',
|
|
333
|
+
pointerEvents: 'none',
|
|
334
|
+
}) }))
|
|
335
|
+
|
|
336
|
+
// ── Header ──
|
|
337
|
+
const header = el('div', { style: ss({
|
|
338
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
339
|
+
marginBottom: '28px', flexShrink: '0',
|
|
340
|
+
}) })
|
|
341
|
+
header.appendChild(el('span', {
|
|
342
|
+
style: ss({ fontSize:'24px', fontWeight:'700', color: C.green,
|
|
343
|
+
letterSpacing: '0.5px' }),
|
|
344
|
+
text: 'CC Insight',
|
|
345
|
+
}))
|
|
346
|
+
header.appendChild(el('span', {
|
|
347
|
+
style: ss({
|
|
348
|
+
fontSize: '13px', color: C.muted, border: `1px solid ${C.border}`,
|
|
349
|
+
borderRadius: '4px', padding: '3px 8px', letterSpacing: '0.5px',
|
|
350
|
+
}),
|
|
351
|
+
text: rangeLabel[data.range] ?? data.range,
|
|
352
|
+
}))
|
|
353
|
+
card.appendChild(header)
|
|
354
|
+
|
|
355
|
+
// ── 称呼 ──
|
|
356
|
+
if (data.nickname) {
|
|
357
|
+
card.appendChild(el('div', {
|
|
358
|
+
style: ss({ fontSize:'19px', color: C.green, letterSpacing:'2px',
|
|
359
|
+
textTransform:'uppercase', marginBottom:'8px', flexShrink:'0' }),
|
|
360
|
+
text: data.nickname,
|
|
361
|
+
}))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── 一句话总结(英雄元素)──
|
|
365
|
+
const summary = el('div', {
|
|
366
|
+
style: ss({
|
|
367
|
+
fontSize: '32px', lineHeight: '1.45', color: C.text,
|
|
368
|
+
fontWeight: '600', marginBottom: '16px', flexShrink: '0',
|
|
369
|
+
wordBreak: 'break-word',
|
|
370
|
+
}),
|
|
371
|
+
text: data.summary ?? '',
|
|
372
|
+
})
|
|
373
|
+
card.appendChild(summary)
|
|
374
|
+
|
|
375
|
+
// ── 人格标签 ──
|
|
376
|
+
if (data.tags?.length) {
|
|
377
|
+
const tagsRow = el('div', { style: ss({
|
|
378
|
+
display: 'flex', flexWrap: 'wrap', gap: '8px',
|
|
379
|
+
marginBottom: '24px', flexShrink: '0',
|
|
380
|
+
}) })
|
|
381
|
+
data.tags.forEach(tag => {
|
|
382
|
+
tagsRow.appendChild(el('span', {
|
|
383
|
+
style: ss({
|
|
384
|
+
fontSize: '14px', color: C.green, whiteSpace: 'nowrap',
|
|
385
|
+
border: `1px solid rgba(74,222,128,0.3)`,
|
|
386
|
+
background: 'rgba(74,222,128,0.08)',
|
|
387
|
+
borderRadius: '4px', padding: '5px 12px',
|
|
388
|
+
letterSpacing: '0.3px',
|
|
389
|
+
}),
|
|
390
|
+
text: tag,
|
|
391
|
+
}))
|
|
392
|
+
})
|
|
393
|
+
card.appendChild(tagsRow)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── 分隔线(有指标或有图表才显示)──
|
|
397
|
+
if (hasMetrics || hasCharts) card.appendChild(divider())
|
|
398
|
+
|
|
399
|
+
// ── 指标卡片 ──
|
|
400
|
+
// 全量指标定义(与 METRIC_OPTIONS 对应)— 必须在 metricsGrid 之前声明
|
|
401
|
+
const ALL_METRIC_DEFS = [
|
|
402
|
+
{ key: 'sessions', value: String(m.sessions ?? 0), label: 'Sessions', color: C.green },
|
|
403
|
+
{ key: 'avgDailyDuration', value: fmtDur(m.avgDailyDurationSec), label: 'Daily Avg', color: C.cyan },
|
|
404
|
+
{ key: 'peak', value: m.peakPeriod?.split('–')[0] ?? '—', label: 'Peak', color: C.amber },
|
|
405
|
+
{ key: 'topSkill', value: fmtSkill(m.topSkillName), label: 'Top Skill', color: C.purple },
|
|
406
|
+
{ key: 'totalDuration', value: fmtDur(m.totalDurationSec), label: 'Total Time', color: C.cyan },
|
|
407
|
+
{ key: 'silentDays', value: String(m.silentDays ?? 0), label: 'Silent Days', color: C.muted },
|
|
408
|
+
]
|
|
409
|
+
const selectedKeys = m._selectedKeys ?? ['sessions','avgDailyDuration','peak','topSkill']
|
|
410
|
+
const metricItems = selectedKeys
|
|
411
|
+
.map(k => ALL_METRIC_DEFS.find(d => d.key === k))
|
|
412
|
+
.filter(Boolean)
|
|
413
|
+
.slice(0, 4)
|
|
414
|
+
|
|
415
|
+
const metricsGrid = el('div', { style: ss({
|
|
416
|
+
display: 'grid', gridTemplateColumns: `repeat(${metricItems.length || 1},1fr)`,
|
|
417
|
+
gap: '8px', margin: '18px 0', flexShrink: '0',
|
|
418
|
+
}) })
|
|
419
|
+
metricItems.forEach(({ value, label, color }) => {
|
|
420
|
+
const card2 = el('div', { style: ss({
|
|
421
|
+
background: C.bg2, border: `1px solid ${C.border}`,
|
|
422
|
+
borderRadius: '6px', padding: '10px 8px', textAlign: 'center',
|
|
423
|
+
}) })
|
|
424
|
+
card2.appendChild(el('div', { style: ss({ fontSize:'26px', fontWeight:'700',
|
|
425
|
+
color, marginBottom:'4px', overflow:'hidden', textOverflow:'ellipsis',
|
|
426
|
+
whiteSpace:'nowrap' }), text: value }))
|
|
427
|
+
card2.appendChild(el('div', { style: ss({ fontSize:'12px', color: C.muted,
|
|
428
|
+
letterSpacing:'0.8px', textTransform:'uppercase' }), text: label }))
|
|
429
|
+
metricsGrid.appendChild(card2)
|
|
430
|
+
})
|
|
431
|
+
if (metricItems.length > 0) card.appendChild(metricsGrid)
|
|
432
|
+
|
|
433
|
+
// ── 分隔线(指标和图表都有时才显示)──
|
|
434
|
+
if (metricItems.length > 0 && hasCharts) card.appendChild(divider())
|
|
435
|
+
|
|
436
|
+
// ── 图表区 ──
|
|
437
|
+
const chartsArea = el('div', { style: ss({
|
|
438
|
+
flex: '1', minHeight: '0', marginTop: '16px',
|
|
439
|
+
display: 'flex', flexDirection: 'column', gap: '14px',
|
|
440
|
+
}) })
|
|
441
|
+
|
|
442
|
+
// Heatmap(仅在有数据时渲染整个容器)
|
|
443
|
+
if ((data.heatmap ?? []).length > 0) {
|
|
444
|
+
const heatSection = el('div', {})
|
|
445
|
+
heatSection.appendChild(sectionLabel('ACTIVITY'))
|
|
446
|
+
heatSection.appendChild(buildHeatmapSVG(data.heatmap, data.range))
|
|
447
|
+
chartsArea.appendChild(heatSection)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 24H Distribution(仅在有数据时渲染整个容器)
|
|
451
|
+
if ((data.distribution ?? []).length > 0) {
|
|
452
|
+
const distSection = el('div', {})
|
|
453
|
+
distSection.appendChild(sectionLabel('PEAK HOURS'))
|
|
454
|
+
distSection.appendChild(buildDistributionSVG(data.distribution))
|
|
455
|
+
chartsArea.appendChild(distSection)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
card.appendChild(chartsArea)
|
|
459
|
+
|
|
460
|
+
// ── 署名条 ──
|
|
461
|
+
card.appendChild(el('div', {
|
|
462
|
+
style: ss({
|
|
463
|
+
marginTop: 'auto', paddingTop: '16px',
|
|
464
|
+
borderTop: `1px solid ${C.border}`,
|
|
465
|
+
fontSize: '13px', color: C.muted,
|
|
466
|
+
textAlign: 'center', letterSpacing: '0.5px',
|
|
467
|
+
flexShrink: '0',
|
|
468
|
+
}),
|
|
469
|
+
text: 'CC Insight @Halooo',
|
|
470
|
+
}))
|
|
471
|
+
|
|
472
|
+
wrap.appendChild(card)
|
|
473
|
+
return wrap
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── SVG:Activity Heatmap(GitHub 贡献图风格,7行×N列)────────
|
|
477
|
+
|
|
478
|
+
function buildHeatmapSVG(heatmap, range) {
|
|
479
|
+
const CELL = 9; const GAP = 2; const LABEL_W = 22
|
|
480
|
+
const CHART_W = 476
|
|
481
|
+
const maxWeeks = Math.floor((CHART_W - LABEL_W + GAP) / (CELL + GAP))
|
|
482
|
+
const DAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', '']
|
|
483
|
+
|
|
484
|
+
if (!heatmap || heatmap.length === 0) return document.createElement('div')
|
|
485
|
+
|
|
486
|
+
const map = {}
|
|
487
|
+
for (const d of heatmap) map[d.day] = d.count
|
|
488
|
+
const maxCount = Math.max(...heatmap.map(d => d.count), 1)
|
|
489
|
+
|
|
490
|
+
function intensity(count) {
|
|
491
|
+
if (!count) return C.bg3
|
|
492
|
+
const p = count / maxCount
|
|
493
|
+
if (p < 0.25) return '#0e4429'
|
|
494
|
+
if (p < 0.5) return '#006d32'
|
|
495
|
+
if (p < 0.75) return '#26a641'
|
|
496
|
+
return '#39d353'
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// 从最早数据对齐到周一,生成所有周
|
|
500
|
+
const today = new Date()
|
|
501
|
+
const firstDay = new Date(heatmap[0].day + 'T12:00:00Z')
|
|
502
|
+
const start = new Date(firstDay)
|
|
503
|
+
start.setDate(start.getDate() - ((start.getUTCDay() + 6) % 7))
|
|
504
|
+
|
|
505
|
+
const weeks = []
|
|
506
|
+
let cur = new Date(start)
|
|
507
|
+
while (cur <= today) {
|
|
508
|
+
const week = []
|
|
509
|
+
for (let d = 0; d < 7; d++) {
|
|
510
|
+
const key = cur.toISOString().slice(0, 10)
|
|
511
|
+
week.push({ day: key, count: map[key] ?? 0 })
|
|
512
|
+
cur.setDate(cur.getDate() + 1)
|
|
513
|
+
}
|
|
514
|
+
weeks.push(week)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// all 模式或超出 maxWeeks:只取最近一屏
|
|
518
|
+
const dataWeeks = weeks.slice(-maxWeeks)
|
|
519
|
+
const padCount = Math.max(0, maxWeeks - dataWeeks.length)
|
|
520
|
+
const emptyWeek = Array(7).fill({ day: '', count: 0 })
|
|
521
|
+
const displayWeeks = [...Array(padCount).fill(emptyWeek), ...dataWeeks]
|
|
522
|
+
const nWeeks = displayWeeks.length
|
|
523
|
+
const svgW = LABEL_W + nWeeks * (CELL + GAP) - GAP
|
|
524
|
+
const svgH = 7 * (CELL + GAP) - GAP
|
|
525
|
+
|
|
526
|
+
let content = ''
|
|
527
|
+
|
|
528
|
+
// Y 轴标签
|
|
529
|
+
DAY_LABELS.forEach((label, i) => {
|
|
530
|
+
if (!label) return
|
|
531
|
+
const y = i * (CELL + GAP) + CELL - 1
|
|
532
|
+
content += `<text x="${LABEL_W - 3}" y="${y}" text-anchor="end"
|
|
533
|
+
font-size="7" fill="${C.muted}"
|
|
534
|
+
font-family="JetBrains Mono,monospace">${label}</text>`
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
// 格子
|
|
538
|
+
displayWeeks.forEach((week, wi) => {
|
|
539
|
+
const x = LABEL_W + wi * (CELL + GAP)
|
|
540
|
+
week.forEach((cell, di) => {
|
|
541
|
+
const y = di * (CELL + GAP)
|
|
542
|
+
content += `<rect x="${x}" y="${y}" width="${CELL}" height="${CELL}"
|
|
543
|
+
rx="2" fill="${intensity(cell.count)}"/>`
|
|
544
|
+
})
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
548
|
+
svg.setAttribute('width', svgW)
|
|
549
|
+
svg.setAttribute('height', svgH)
|
|
550
|
+
svg.setAttribute('viewBox', `0 0 ${svgW} ${svgH}`)
|
|
551
|
+
svg.innerHTML = content
|
|
552
|
+
return svg
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ── SVG:24H 时间分布条形图 ──────────────────────────────────
|
|
556
|
+
|
|
557
|
+
function buildDistributionSVG(distribution) {
|
|
558
|
+
const W = 476; const BAR_H = 44; const LABEL_H = 16
|
|
559
|
+
const TOTAL_H = BAR_H + LABEL_H
|
|
560
|
+
const barW = Math.floor(W / 24) - 1
|
|
561
|
+
const maxCount = Math.max(...distribution.map(d => d.count), 1)
|
|
562
|
+
const countMap = Object.fromEntries(distribution.map(d => [parseInt(d.hour), d.count]))
|
|
563
|
+
|
|
564
|
+
// 前 3 活跃时间段,过滤相邻(间距 < 3h)避免标签重叠
|
|
565
|
+
const top3 = new Set()
|
|
566
|
+
for (const d of [...distribution].sort((a, b) => b.count - a.count)) {
|
|
567
|
+
const h = parseInt(d.hour)
|
|
568
|
+
if ([...top3].every(existing => Math.abs(existing - h) >= 3)) {
|
|
569
|
+
top3.add(h)
|
|
570
|
+
if (top3.size === 3) break
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const bars = Array.from({ length: 24 }, (_, h) => {
|
|
575
|
+
const count = countMap[h] ?? 0
|
|
576
|
+
const barH = count > 0 ? Math.max(3, Math.round((count / maxCount) * (BAR_H - 4))) : 2
|
|
577
|
+
const x = h * (barW + 1)
|
|
578
|
+
const y = BAR_H - barH
|
|
579
|
+
const opacity = count > 0 ? (0.3 + (count / maxCount) * 0.7) : 0.15
|
|
580
|
+
const bar = `<rect x="${x}" y="${y}" width="${barW}" height="${barH}"
|
|
581
|
+
rx="2" fill="rgba(34,211,238,${opacity.toFixed(2)})"/>`
|
|
582
|
+
const label = top3.has(h)
|
|
583
|
+
? `<text x="${x + barW / 2}" y="${TOTAL_H}" text-anchor="middle"
|
|
584
|
+
font-size="9" fill="rgba(34,211,238,0.85)"
|
|
585
|
+
font-family="JetBrains Mono,monospace">${String(h).padStart(2, '0')}:00</text>`
|
|
586
|
+
: ''
|
|
587
|
+
return bar + label
|
|
588
|
+
}).join('')
|
|
589
|
+
|
|
590
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
591
|
+
svg.setAttribute('width', W)
|
|
592
|
+
svg.setAttribute('height', TOTAL_H)
|
|
593
|
+
svg.setAttribute('viewBox', `0 0 ${W} ${TOTAL_H}`)
|
|
594
|
+
svg.innerHTML = bars
|
|
595
|
+
return svg
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ── 工具函数 ──────────────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
function el(tag, { style, text, id } = {}) {
|
|
601
|
+
const node = document.createElement(tag)
|
|
602
|
+
if (style) node.style.cssText = style
|
|
603
|
+
if (text !== undefined) node.textContent = text
|
|
604
|
+
if (id) node.id = id
|
|
605
|
+
return node
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** 对象样式转 cssText 字符串(camelCase key → kebab-case) */
|
|
609
|
+
function ss(obj) {
|
|
610
|
+
return Object.entries(obj).map(([k, v]) =>
|
|
611
|
+
k.replace(/[A-Z]/g, c => '-' + c.toLowerCase()) + ':' + v
|
|
612
|
+
).join(';')
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function divider() {
|
|
616
|
+
return el('div', { style: ss({
|
|
617
|
+
height: '1px', background: C.border, flexShrink: '0',
|
|
618
|
+
}) })
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function sectionLabel(text) {
|
|
622
|
+
const d = el('div', { style: ss({
|
|
623
|
+
fontSize: '12px', color: C.muted,
|
|
624
|
+
letterSpacing: '1.5px', textTransform: 'uppercase',
|
|
625
|
+
marginBottom: '8px',
|
|
626
|
+
}) })
|
|
627
|
+
d.textContent = text
|
|
628
|
+
return d
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function fmtDur(sec) {
|
|
632
|
+
if (!sec) return '0m'
|
|
633
|
+
const h = Math.floor(sec / 3600)
|
|
634
|
+
const m = Math.floor((sec % 3600) / 60)
|
|
635
|
+
return h > 0 ? `${h}h${m}m` : `${m}m`
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function fmtSkill(name) {
|
|
639
|
+
if (!name) return '—'
|
|
640
|
+
// 截断并首字母大写
|
|
641
|
+
const s = name.length > 8 ? name.slice(0, 7) + '…' : name
|
|
642
|
+
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function esc(str = '') {
|
|
646
|
+
return String(str)
|
|
647
|
+
.replace(/&/g, '&').replace(/</g, '<')
|
|
648
|
+
.replace(/>/g, '>').replace(/"/g, '"')
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ── P7:编辑面板 ──────────────────────────────────────────────
|
|
652
|
+
|
|
653
|
+
// 可选指标列表
|
|
654
|
+
const METRIC_OPTIONS = [
|
|
655
|
+
{ key: 'sessions', label: 'Sessions', fmt: d => String(d.metrics?.sessions ?? 0) },
|
|
656
|
+
{ key: 'avgDailyDuration', label: 'Daily Avg', fmt: d => fmtDur(d.metrics?.avgDailyDurationSec) },
|
|
657
|
+
{ key: 'peak', label: 'Peak Hour', fmt: d => d.metrics?.peakPeriod?.split('–')[0] ?? '—' },
|
|
658
|
+
{ key: 'topSkill', label: 'Top Skill', fmt: d => fmtSkill(d.metrics?.topSkillName) },
|
|
659
|
+
{ key: 'totalDuration', label: 'Total Time', fmt: d => fmtDur(d.metrics?.totalDurationSec) },
|
|
660
|
+
{ key: 'silentDays', label: 'Silent Days', fmt: d => String(d.metrics?.silentDays ?? 0) },
|
|
661
|
+
]
|
|
662
|
+
|
|
663
|
+
// 可选图表列表
|
|
664
|
+
const CHART_OPTIONS = [
|
|
665
|
+
{ key: 'heatmap', label: 'Activity Heatmap' },
|
|
666
|
+
{ key: 'distribution', label: '24H Distribution' },
|
|
667
|
+
]
|
|
668
|
+
|
|
669
|
+
// 当前编辑状态(每次弹窗重置)
|
|
670
|
+
let _edit = null
|
|
671
|
+
|
|
672
|
+
function initEditState(data) {
|
|
673
|
+
_edit = {
|
|
674
|
+
nickname: data.nickname ?? '',
|
|
675
|
+
summary: data.summary ?? '',
|
|
676
|
+
tags: [...(data.tags ?? [])],
|
|
677
|
+
selectedMetrics: ['sessions', 'avgDailyDuration', 'peak', 'topSkill'],
|
|
678
|
+
selectedCharts: ['heatmap', 'distribution'],
|
|
679
|
+
summaryIdx: 0,
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function renderEditPanel(data) {
|
|
684
|
+
initEditState(data)
|
|
685
|
+
const body = document.getElementById('poster-edit-body')
|
|
686
|
+
if (!body) return
|
|
687
|
+
|
|
688
|
+
body.innerHTML = ''
|
|
689
|
+
|
|
690
|
+
// ── 称呼 ──
|
|
691
|
+
body.appendChild(buildField({
|
|
692
|
+
label: '称呼',
|
|
693
|
+
hint: '最多 10 个字符,显示在海报上',
|
|
694
|
+
input: buildInput('text', _edit.nickname, 10, val => {
|
|
695
|
+
_edit.nickname = val
|
|
696
|
+
refreshPoster(data)
|
|
697
|
+
// 持久化
|
|
698
|
+
fetch('/api/config', { method: 'POST',
|
|
699
|
+
headers: { 'Content-Type': 'application/json' },
|
|
700
|
+
body: JSON.stringify({ posterNickname: val }) })
|
|
701
|
+
}),
|
|
702
|
+
}))
|
|
703
|
+
|
|
704
|
+
// ── // OUTPUT ──
|
|
705
|
+
body.appendChild(buildSummaryField(data))
|
|
706
|
+
|
|
707
|
+
// ── AI 人格标签 ──
|
|
708
|
+
body.appendChild(buildTagsDisplay(data.tags ?? []))
|
|
709
|
+
|
|
710
|
+
// ── 数据卡片选择:指标 ──
|
|
711
|
+
body.appendChild(buildCheckGroup({
|
|
712
|
+
label: '指标卡片(最多 4 个)',
|
|
713
|
+
options: METRIC_OPTIONS,
|
|
714
|
+
selected: _edit.selectedMetrics,
|
|
715
|
+
max: 4,
|
|
716
|
+
onChange: keys => { _edit.selectedMetrics = keys; refreshPoster(data) },
|
|
717
|
+
}))
|
|
718
|
+
|
|
719
|
+
// ── 数据卡片选择:图表 ──
|
|
720
|
+
body.appendChild(buildCheckGroup({
|
|
721
|
+
label: '图表(最多 2 个)',
|
|
722
|
+
options: CHART_OPTIONS,
|
|
723
|
+
selected: _edit.selectedCharts,
|
|
724
|
+
max: 2,
|
|
725
|
+
onChange: keys => { _edit.selectedCharts = keys; refreshPoster(data) },
|
|
726
|
+
}))
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ── 字段容器 ────────────────────────────────────────────────
|
|
730
|
+
|
|
731
|
+
function buildField({ label, hint, input }) {
|
|
732
|
+
const sec = document.createElement('div')
|
|
733
|
+
sec.className = 'poster-section'
|
|
734
|
+
|
|
735
|
+
const header = document.createElement('div')
|
|
736
|
+
header.className = 'poster-section-header'
|
|
737
|
+
|
|
738
|
+
const lbl = document.createElement('div')
|
|
739
|
+
lbl.className = 'poster-section-title'
|
|
740
|
+
lbl.textContent = label
|
|
741
|
+
header.appendChild(lbl)
|
|
742
|
+
|
|
743
|
+
if (hint) {
|
|
744
|
+
const h = document.createElement('div')
|
|
745
|
+
h.className = 'poster-section-hint'
|
|
746
|
+
h.textContent = hint
|
|
747
|
+
header.appendChild(h)
|
|
748
|
+
}
|
|
749
|
+
sec.appendChild(header)
|
|
750
|
+
sec.appendChild(input)
|
|
751
|
+
return sec
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function buildInput(type, value, maxLen, onChange) {
|
|
755
|
+
const inp = document.createElement('input')
|
|
756
|
+
inp.type = type
|
|
757
|
+
inp.className = 'poster-input'
|
|
758
|
+
inp.value = value
|
|
759
|
+
inp.maxLength = maxLen
|
|
760
|
+
inp.addEventListener('input', () => onChange(inp.value))
|
|
761
|
+
return inp
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function buildTextarea(value, onChange) {
|
|
765
|
+
const ta = document.createElement('textarea')
|
|
766
|
+
ta.className = 'poster-input'
|
|
767
|
+
ta.value = value
|
|
768
|
+
ta.rows = 3
|
|
769
|
+
ta.style.resize = 'vertical'
|
|
770
|
+
ta.addEventListener('input', () => onChange(ta.value))
|
|
771
|
+
return ta
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ── 标签展示(只读,自动生成)──────────────────────────────
|
|
775
|
+
|
|
776
|
+
function buildTagsDisplay(tags) {
|
|
777
|
+
const sec = document.createElement('div')
|
|
778
|
+
sec.className = 'poster-section'
|
|
779
|
+
|
|
780
|
+
const header = document.createElement('div')
|
|
781
|
+
header.className = 'poster-section-header'
|
|
782
|
+
|
|
783
|
+
const lbl = document.createElement('div')
|
|
784
|
+
lbl.className = 'poster-section-title'
|
|
785
|
+
lbl.textContent = 'AI 人格标签'
|
|
786
|
+
header.appendChild(lbl)
|
|
787
|
+
|
|
788
|
+
const hint = document.createElement('div')
|
|
789
|
+
hint.className = 'poster-section-hint'
|
|
790
|
+
hint.textContent = '根据使用数据自动生成'
|
|
791
|
+
header.appendChild(hint)
|
|
792
|
+
|
|
793
|
+
sec.appendChild(header)
|
|
794
|
+
|
|
795
|
+
const row = document.createElement('div')
|
|
796
|
+
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;'
|
|
797
|
+
tags.forEach(tag => {
|
|
798
|
+
const chip = document.createElement('span')
|
|
799
|
+
chip.style.cssText = `font-size:13px;color:var(--green);white-space:nowrap;
|
|
800
|
+
border:1px solid rgba(74,222,128,0.3);background:rgba(74,222,128,0.08);
|
|
801
|
+
border-radius:4px;padding:4px 10px;font-family:var(--font);`
|
|
802
|
+
chip.textContent = tag
|
|
803
|
+
row.appendChild(chip)
|
|
804
|
+
})
|
|
805
|
+
sec.appendChild(row)
|
|
806
|
+
|
|
807
|
+
return sec
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ── 复选框组 ────────────────────────────────────────────────
|
|
811
|
+
|
|
812
|
+
function buildCheckGroup({ label, options, selected, max, onChange }) {
|
|
813
|
+
const sec = document.createElement('div')
|
|
814
|
+
sec.className = 'poster-section'
|
|
815
|
+
|
|
816
|
+
const lbl = document.createElement('div')
|
|
817
|
+
lbl.className = 'poster-section-title'
|
|
818
|
+
lbl.textContent = label
|
|
819
|
+
sec.appendChild(lbl)
|
|
820
|
+
|
|
821
|
+
const grid = document.createElement('div')
|
|
822
|
+
grid.style.cssText = 'display:flex;flex-direction:column;gap:6px;'
|
|
823
|
+
|
|
824
|
+
const checkboxes = []
|
|
825
|
+
options.forEach(opt => {
|
|
826
|
+
const row = document.createElement('label')
|
|
827
|
+
row.style.cssText = `display:flex;align-items:center;gap:8px;
|
|
828
|
+
cursor:pointer;font-size:13px;color:var(--text);padding:4px 0;`
|
|
829
|
+
|
|
830
|
+
const cb = document.createElement('input')
|
|
831
|
+
cb.type = 'checkbox'
|
|
832
|
+
cb.checked = selected.includes(opt.key)
|
|
833
|
+
cb.style.cssText = 'accent-color:var(--green);width:14px;height:14px;cursor:pointer;'
|
|
834
|
+
checkboxes.push({ key: opt.key, cb })
|
|
835
|
+
|
|
836
|
+
cb.addEventListener('change', () => {
|
|
837
|
+
const now = checkboxes.filter(c => c.cb.checked).map(c => c.key)
|
|
838
|
+
// 超过上限时撤销本次勾选
|
|
839
|
+
if (cb.checked && now.length > max) {
|
|
840
|
+
cb.checked = false
|
|
841
|
+
return
|
|
842
|
+
}
|
|
843
|
+
onChange(checkboxes.filter(c => c.cb.checked).map(c => c.key))
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
const txt = document.createElement('span')
|
|
847
|
+
txt.textContent = opt.label
|
|
848
|
+
row.appendChild(cb)
|
|
849
|
+
row.appendChild(txt)
|
|
850
|
+
grid.appendChild(row)
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
sec.appendChild(grid)
|
|
854
|
+
return sec
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ── 实时刷新海报预览 ─────────────────────────────────────────
|
|
858
|
+
|
|
859
|
+
function refreshPoster(data) {
|
|
860
|
+
const wrap = document.getElementById('poster-canvas-wrap')
|
|
861
|
+
if (!wrap || !_edit) return
|
|
862
|
+
|
|
863
|
+
// 用编辑状态覆盖原始数据
|
|
864
|
+
const merged = {
|
|
865
|
+
...data,
|
|
866
|
+
nickname: _edit.nickname,
|
|
867
|
+
summary: _edit.summary,
|
|
868
|
+
tags: _edit.tags,
|
|
869
|
+
metrics: buildFilteredMetrics(data, _edit.selectedMetrics),
|
|
870
|
+
heatmap: _edit.selectedCharts.includes('heatmap') ? data.heatmap : [],
|
|
871
|
+
distribution: _edit.selectedCharts.includes('distribution') ? data.distribution : [],
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
wrap.innerHTML = ''
|
|
875
|
+
wrap.appendChild(buildPosterCard(merged))
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ── Summary 只读区块 + 刷新按钮 ─────────────────────────────
|
|
879
|
+
|
|
880
|
+
function buildSummaryField(data) {
|
|
881
|
+
const sec = document.createElement('div')
|
|
882
|
+
sec.className = 'poster-section'
|
|
883
|
+
|
|
884
|
+
const header = document.createElement('div')
|
|
885
|
+
header.className = 'poster-section-header'
|
|
886
|
+
|
|
887
|
+
const lbl = document.createElement('div')
|
|
888
|
+
lbl.className = 'poster-section-title'
|
|
889
|
+
lbl.textContent = '签名'
|
|
890
|
+
header.appendChild(lbl)
|
|
891
|
+
|
|
892
|
+
const right = document.createElement('div')
|
|
893
|
+
right.style.cssText = 'display:flex;align-items:center;gap:8px;'
|
|
894
|
+
|
|
895
|
+
const counter = document.createElement('span')
|
|
896
|
+
counter.style.cssText = 'font-size:11px;color:var(--muted);'
|
|
897
|
+
counter.id = 'summary-counter'
|
|
898
|
+
right.appendChild(counter)
|
|
899
|
+
|
|
900
|
+
const refreshBtn = document.createElement('button')
|
|
901
|
+
refreshBtn.style.cssText = `display:flex;align-items:center;gap:4px;padding:2px 8px;
|
|
902
|
+
border:1px solid var(--border);border-radius:4px;background:transparent;
|
|
903
|
+
color:var(--muted);font-size:11px;font-family:var(--font);cursor:pointer;transition:all 0.15s;`
|
|
904
|
+
refreshBtn.innerHTML = `<svg width="11" height="11" viewBox="0 0 12 12" fill="none"
|
|
905
|
+
stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
|
906
|
+
<path d="M10 6A4 4 0 1 1 8.5 2.5"/><path d="M8 1l1 2-2 .5"/>
|
|
907
|
+
</svg> 换一句`
|
|
908
|
+
refreshBtn.addEventListener('mouseover', () => {
|
|
909
|
+
refreshBtn.style.borderColor = 'var(--green)'
|
|
910
|
+
refreshBtn.style.color = 'var(--green)'
|
|
911
|
+
})
|
|
912
|
+
refreshBtn.addEventListener('mouseout', () => {
|
|
913
|
+
refreshBtn.style.borderColor = 'var(--border)'
|
|
914
|
+
refreshBtn.style.color = 'var(--muted)'
|
|
915
|
+
})
|
|
916
|
+
right.appendChild(refreshBtn)
|
|
917
|
+
header.appendChild(right)
|
|
918
|
+
sec.appendChild(header)
|
|
919
|
+
|
|
920
|
+
const display = document.createElement('div')
|
|
921
|
+
display.id = 'summary-display'
|
|
922
|
+
display.style.cssText = `background:var(--bg);border:1px solid var(--border);
|
|
923
|
+
border-radius:var(--radius);padding:10px;color:var(--text);
|
|
924
|
+
font-size:13px;line-height:1.6;min-height:60px;`
|
|
925
|
+
display.textContent = _edit.summary
|
|
926
|
+
sec.appendChild(display)
|
|
927
|
+
|
|
928
|
+
updateSummaryCounter(data.summaryCount ?? 1, 0)
|
|
929
|
+
|
|
930
|
+
refreshBtn.addEventListener('click', async () => {
|
|
931
|
+
refreshBtn.disabled = true
|
|
932
|
+
refreshBtn.style.opacity = '0.5'
|
|
933
|
+
_edit.summaryIdx = ((_edit.summaryIdx ?? 0) + 1)
|
|
934
|
+
try {
|
|
935
|
+
const r = await fetch(`/api/poster/generate-text?range=${data.range}&seed=${_edit.summaryIdx}`)
|
|
936
|
+
const d = await r.json()
|
|
937
|
+
_edit.summary = d.summary
|
|
938
|
+
const disp = document.getElementById('summary-display')
|
|
939
|
+
if (disp) disp.textContent = d.summary
|
|
940
|
+
updateSummaryCounter(d.summaryCount ?? 1, _edit.summaryIdx)
|
|
941
|
+
refreshPoster(data)
|
|
942
|
+
} finally {
|
|
943
|
+
refreshBtn.disabled = false
|
|
944
|
+
refreshBtn.style.opacity = '1'
|
|
945
|
+
}
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
return sec
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function updateSummaryCounter(total, idx) {
|
|
952
|
+
const counterEl = document.getElementById('summary-counter')
|
|
953
|
+
if (counterEl && total > 1) counterEl.textContent = `${(idx % total) + 1} / ${total}`
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// 根据选中的 key 重组指标顺序,未选中的不传入(卡片区留空)
|
|
957
|
+
function buildFilteredMetrics(data, keys) {
|
|
958
|
+
const base = data.metrics ?? {}
|
|
959
|
+
const colorMap = {
|
|
960
|
+
sessions: C.green,
|
|
961
|
+
avgDailyDuration: C.cyan,
|
|
962
|
+
peak: C.amber,
|
|
963
|
+
topSkill: C.purple,
|
|
964
|
+
totalDuration: C.cyan,
|
|
965
|
+
silentDays: C.muted,
|
|
966
|
+
}
|
|
967
|
+
return {
|
|
968
|
+
...base,
|
|
969
|
+
_selectedKeys: keys, // 传给 buildPosterCard 使用
|
|
970
|
+
_colorMap: colorMap,
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ── P8:图片导出 ──────────────────────────────────────────────
|
|
975
|
+
|
|
976
|
+
function loadDomToImage() {
|
|
977
|
+
if (window.domtoimage) return Promise.resolve(window.domtoimage)
|
|
978
|
+
return new Promise((resolve, reject) => {
|
|
979
|
+
const s = document.createElement('script')
|
|
980
|
+
s.src = 'https://unpkg.com/dom-to-image-more@3.4.0/dist/dom-to-image-more.min.js'
|
|
981
|
+
s.onload = () => resolve(window.domtoimage)
|
|
982
|
+
s.onerror = () => reject(new Error('无法加载图片导出库,请检查网络连接'))
|
|
983
|
+
document.head.appendChild(s)
|
|
984
|
+
})
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async function exportPoster(action) {
|
|
988
|
+
const card = document.getElementById('poster-card')
|
|
989
|
+
if (!card) return
|
|
990
|
+
|
|
991
|
+
const btnId = action === 'copy' ? 'poster-copy-btn' : 'poster-download-btn'
|
|
992
|
+
const btn = document.getElementById(btnId)
|
|
993
|
+
const origTx = btn.textContent
|
|
994
|
+
btn.textContent = '生成中…'
|
|
995
|
+
btn.disabled = true
|
|
996
|
+
|
|
997
|
+
try {
|
|
998
|
+
const domtoimage = await loadDomToImage()
|
|
999
|
+
|
|
1000
|
+
const scale = PREVIEW_W / POSTER_W
|
|
1001
|
+
const dataUrl = await domtoimage.toPng(card, {
|
|
1002
|
+
width: POSTER_W,
|
|
1003
|
+
height: card.scrollHeight,
|
|
1004
|
+
style: { transform: 'none', transformOrigin: 'top left' },
|
|
1005
|
+
})
|
|
1006
|
+
|
|
1007
|
+
if (action === 'copy') {
|
|
1008
|
+
const blob = await (await fetch(dataUrl)).blob()
|
|
1009
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
|
1010
|
+
btn.textContent = '已复制 ✓'
|
|
1011
|
+
setTimeout(() => { btn.textContent = origTx; btn.disabled = false }, 2000)
|
|
1012
|
+
} else {
|
|
1013
|
+
const a = document.createElement('a')
|
|
1014
|
+
a.href = dataUrl
|
|
1015
|
+
a.download = `cc-insight-poster-${Date.now()}.png`
|
|
1016
|
+
a.click()
|
|
1017
|
+
btn.textContent = origTx
|
|
1018
|
+
btn.disabled = false
|
|
1019
|
+
}
|
|
1020
|
+
} catch (e) {
|
|
1021
|
+
console.error('[poster export]', e)
|
|
1022
|
+
btn.textContent = '导出失败'
|
|
1023
|
+
setTimeout(() => { btn.textContent = origTx; btn.disabled = false }, 2500)
|
|
1024
|
+
}
|
|
1025
|
+
}
|