wukong-gitlog-cli 1.0.12 → 1.0.14
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/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/cli.mjs +5 -5
- package/src/server.mjs +2 -2
- package/src/utils/output.mjs +2 -2
- package/web/app.js +711 -37
- package/web/index.html +33 -1
- package/web/static/style.css +105 -15
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [1.0.14](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.13...v1.0.14) (2025-12-02)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* 🎸 charts show better ([bfceb54](https://github.com/tomatobybike/wukong-gitlog-cli/commit/bfceb54b2bd67cc6bd67d93550799592119daff2))
|
|
11
|
+
|
|
12
|
+
### [1.0.13](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.12...v1.0.13) (2025-12-01)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* 🎸 output-wukong ([c310623](https://github.com/tomatobybike/wukong-gitlog-cli/commit/c310623314c44bad792b55967c5aeabda5e61812))
|
|
18
|
+
|
|
5
19
|
### [1.0.12](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.11...v1.0.12) (2025-12-01)
|
|
6
20
|
|
|
7
21
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -115,11 +115,11 @@ const main = async () => {
|
|
|
115
115
|
.option('--out <file>', '输出文件名(不含路径)')
|
|
116
116
|
.option(
|
|
117
117
|
'--out-dir <dir>',
|
|
118
|
-
'自定义输出目录,支持相对路径或绝对路径,例如 `--out-dir ../output`'
|
|
118
|
+
'自定义输出目录,支持相对路径或绝对路径,例如 `--out-dir ../output-wukong`'
|
|
119
119
|
)
|
|
120
120
|
.option(
|
|
121
121
|
'--out-parent',
|
|
122
|
-
'将输出目录放到当前工程的父目录的 `output/`(等同于 `--out-dir ../output`)'
|
|
122
|
+
'将输出目录放到当前工程的父目录的 `output-wukong/`(等同于 `--out-dir ../output-wukong`)'
|
|
123
123
|
)
|
|
124
124
|
.option(
|
|
125
125
|
'--per-period-formats <formats>',
|
|
@@ -137,7 +137,7 @@ const main = async () => {
|
|
|
137
137
|
)
|
|
138
138
|
.option(
|
|
139
139
|
'--serve',
|
|
140
|
-
'启动本地 web 服务,查看提交统计(将在 output/data 下生成数据文件)'
|
|
140
|
+
'启动本地 web 服务,查看提交统计(将在 output-wukong/data 下生成数据文件)'
|
|
141
141
|
)
|
|
142
142
|
.option(
|
|
143
143
|
'--port <n>',
|
|
@@ -147,7 +147,7 @@ const main = async () => {
|
|
|
147
147
|
)
|
|
148
148
|
.option(
|
|
149
149
|
'--serve-only',
|
|
150
|
-
'仅启动 web 服务,不导出或分析数据(使用 output/data 中已有的数据)'
|
|
150
|
+
'仅启动 web 服务,不导出或分析数据(使用 output-wukong/data 中已有的数据)'
|
|
151
151
|
)
|
|
152
152
|
.option('--version', 'show version information')
|
|
153
153
|
.parse()
|
|
@@ -155,7 +155,7 @@ const main = async () => {
|
|
|
155
155
|
const opts = program.opts()
|
|
156
156
|
// compute output directory root early (so serve-only can use it)
|
|
157
157
|
const outDir = opts.outParent
|
|
158
|
-
? path.resolve(process.cwd(), '..', 'output')
|
|
158
|
+
? path.resolve(process.cwd(), '..', 'output-wukong')
|
|
159
159
|
: opts.outDir || undefined
|
|
160
160
|
|
|
161
161
|
if (opts.version) {
|
package/src/server.mjs
CHANGED
|
@@ -48,7 +48,7 @@ export function startServer(port = 3000, outputDir) {
|
|
|
48
48
|
const webRoot = path.resolve(pkgRoot, 'web')
|
|
49
49
|
const dataRoot = outputDir
|
|
50
50
|
? path.resolve(outputDir)
|
|
51
|
-
: path.resolve(process.cwd(), 'output')
|
|
51
|
+
: path.resolve(process.cwd(), 'output-wukong')
|
|
52
52
|
|
|
53
53
|
// warn if web directory or data directory doesn't exist
|
|
54
54
|
if (!fs.existsSync(webRoot)) {
|
|
@@ -118,7 +118,7 @@ export function startServer(port = 3000, outputDir) {
|
|
|
118
118
|
server.listen(port, () => {
|
|
119
119
|
const url = `http://localhost:${port}`
|
|
120
120
|
console.log(chalk.green(`Server started at ${url}`))
|
|
121
|
-
console.log(chalk.green(`Serving web/ and output/data/`))
|
|
121
|
+
console.log(chalk.green(`Serving web/ and output-wukong/data/`))
|
|
122
122
|
|
|
123
123
|
// ====== 自动打开浏览器 ======
|
|
124
124
|
openBrowser(url)
|
package/src/utils/output.mjs
CHANGED
|
@@ -3,10 +3,10 @@ import path from 'path';
|
|
|
3
3
|
|
|
4
4
|
export function ensureOutputDir(customDir) {
|
|
5
5
|
// If a custom absolute/relative path is provided, resolve relative to cwd as-is
|
|
6
|
-
// Otherwise default to `output` inside current working directory.
|
|
6
|
+
// Otherwise default to `output-wukong` inside current working directory.
|
|
7
7
|
const dir = customDir
|
|
8
8
|
? path.resolve(process.cwd(), customDir)
|
|
9
|
-
: path.resolve(process.cwd(), 'output');
|
|
9
|
+
: path.resolve(process.cwd(), 'output-wukong');
|
|
10
10
|
|
|
11
11
|
if (!fs.existsSync(dir)) {
|
|
12
12
|
fs.mkdirSync(dir, { recursive: true });
|
package/web/app.js
CHANGED
|
@@ -100,23 +100,184 @@ function initTableControls() {
|
|
|
100
100
|
})
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
function drawHourlyOvertime(stats) {
|
|
103
|
+
function drawHourlyOvertime(stats, onHourClick) {
|
|
104
104
|
const el = document.getElementById('hourlyOvertimeChart')
|
|
105
|
-
// eslint-disable-next-line no-undef
|
|
106
105
|
const chart = echarts.init(el)
|
|
107
|
-
|
|
106
|
+
|
|
107
|
+
const commits = stats.hourlyOvertimeCommits || []
|
|
108
|
+
const percent = stats.hourlyOvertimePercent || []
|
|
108
109
|
const labels = Array.from({ length: 24 }, (_, i) =>
|
|
109
110
|
String(i).padStart(2, '0')
|
|
110
111
|
)
|
|
112
|
+
|
|
113
|
+
// 颜色逻辑(与 daily severity 风格一致)
|
|
114
|
+
function getColor(h) {
|
|
115
|
+
if (h >= 21) return '#d32f2f' // 深夜加班 红
|
|
116
|
+
if (h >= 19) return '#fb8c00' // 夜间加班 橙
|
|
117
|
+
if (h >= stats.lunchStart && h < stats.lunchEnd) return '#888888' // 午休灰
|
|
118
|
+
if (h >= stats.startHour && h < stats.endHour) return '#1976d2' // 工作时段 蓝
|
|
119
|
+
return '#b71c1c' // 凌晨 红
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const data = commits.map((v, h) => ({
|
|
123
|
+
value: v,
|
|
124
|
+
itemStyle: { color: getColor(h) }
|
|
125
|
+
}))
|
|
126
|
+
|
|
111
127
|
chart.setOption({
|
|
112
|
-
tooltip: {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
128
|
+
tooltip: {
|
|
129
|
+
trigger: 'axis',
|
|
130
|
+
formatter(params) {
|
|
131
|
+
const p = params[0]
|
|
132
|
+
const h = parseInt(p.axisValue,10)
|
|
133
|
+
const count = p.value
|
|
134
|
+
const rate = (percent[h] * 100).toFixed(1)
|
|
135
|
+
return `
|
|
136
|
+
🕒 <b>${h}:00</b><br/>
|
|
137
|
+
提交次数:<b>${count}</b><br/>
|
|
138
|
+
占全天比例:<b>${rate}%</b>
|
|
139
|
+
`
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
xAxis: {
|
|
144
|
+
type: 'category',
|
|
145
|
+
data: labels,
|
|
146
|
+
axisLabel: { color: '#555' }
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
yAxis: {
|
|
150
|
+
type: 'value',
|
|
151
|
+
min: 0,
|
|
152
|
+
axisLabel: { color: '#555' }
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
grid: { left: 40, right: 30, top: 20, bottom: 40 },
|
|
156
|
+
|
|
157
|
+
series: [
|
|
158
|
+
{
|
|
159
|
+
type: 'bar',
|
|
160
|
+
name: 'Overtime commits',
|
|
161
|
+
data,
|
|
162
|
+
barWidth: 18,
|
|
163
|
+
|
|
164
|
+
markPoint: {
|
|
165
|
+
symbol: 'pin',
|
|
166
|
+
symbolSize: 45,
|
|
167
|
+
itemStyle: { color: '#d32f2f' },
|
|
168
|
+
data: [
|
|
169
|
+
{
|
|
170
|
+
name: '最晚提交',
|
|
171
|
+
coord: [
|
|
172
|
+
String(stats.latestCommitHour).padStart(2, '0'),
|
|
173
|
+
commits[stats.latestCommitHour]
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
]
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
markLine: {
|
|
180
|
+
symbol: 'none',
|
|
181
|
+
animation: true,
|
|
182
|
+
label: { color: '#888', formatter: '{b}' },
|
|
183
|
+
lineStyle: { type: 'dashed', color: '#aaa' },
|
|
184
|
+
data: [
|
|
185
|
+
{
|
|
186
|
+
name: '上班开始',
|
|
187
|
+
xAxis: String(stats.startHour).padStart(2, '0')
|
|
188
|
+
},
|
|
189
|
+
{ name: '下班时间', xAxis: String(stats.endHour).padStart(2, '0') },
|
|
190
|
+
{
|
|
191
|
+
name: '午休开始',
|
|
192
|
+
xAxis: String(stats.lunchStart).padStart(2, '0')
|
|
193
|
+
},
|
|
194
|
+
{ name: '午休结束', xAxis: String(stats.lunchEnd).padStart(2, '0') }
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
]
|
|
116
199
|
})
|
|
200
|
+
|
|
201
|
+
// 点击事件(点击某小时 → 打开侧栏)
|
|
202
|
+
if (typeof onHourClick === 'function') {
|
|
203
|
+
chart.on('click', (p) => {
|
|
204
|
+
const hour = Number(p.name)
|
|
205
|
+
onHourClick(hour, commits[hour])
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
117
209
|
return chart
|
|
118
210
|
}
|
|
119
211
|
|
|
212
|
+
// showSideBarForHour 实现
|
|
213
|
+
function showSideBarForHour(hour, commitsOrCount) {
|
|
214
|
+
// 支持传入 number(仅次数)或 array(详细 commit 列表)
|
|
215
|
+
const sidebar = document.getElementById('hourDetailSidebar')
|
|
216
|
+
const titleEl = document.getElementById('hourSidebarTitle')
|
|
217
|
+
const contentEl = document.getElementById('hourSidebarContent')
|
|
218
|
+
|
|
219
|
+
// 兼容未传入侧栏 DOM 的情况(优雅降级)
|
|
220
|
+
if (!sidebar || !titleEl || !contentEl) {
|
|
221
|
+
console.warn(
|
|
222
|
+
'hourDetailSidebar DOM not found. Please add the HTML snippet.'
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
titleEl.innerHTML = `🕒 ${String(hour).padStart(2, '0')}:00 - ${String(hour).padStart(2, '0')}:59`
|
|
228
|
+
|
|
229
|
+
// 如果只是 number,显示计数
|
|
230
|
+
if (typeof commitsOrCount === 'number') {
|
|
231
|
+
contentEl.innerHTML = `<div style="font-size:14px;">提交次数:<b>${commitsOrCount}</b></div>`
|
|
232
|
+
} else if (Array.isArray(commitsOrCount) && commitsOrCount.length === 0) {
|
|
233
|
+
contentEl.innerHTML = `<div style="font-size:14px;">当小时无提交记录</div>`
|
|
234
|
+
} else if (Array.isArray(commitsOrCount)) {
|
|
235
|
+
// commits 列表:展示作者/时间/消息(最多前 50 条,避免性能问题)
|
|
236
|
+
const commits = commitsOrCount.slice(0, 50)
|
|
237
|
+
contentEl.innerHTML = commits
|
|
238
|
+
.map((c) => {
|
|
239
|
+
const author = c.author ?? c.name ?? 'unknown'
|
|
240
|
+
const time = c.date ?? c.time ?? ''
|
|
241
|
+
const msg = (c.message ?? c.msg ?? c.body ?? '').replace(/\n/g, ' ')
|
|
242
|
+
return `
|
|
243
|
+
<div class="hour-commit">
|
|
244
|
+
<div class="meta">👤 <b>${escapeHtml(author)}</b> · 🕒 ${escapeHtml(time)}</div>
|
|
245
|
+
<div class="msg">${escapeHtml(msg)}</div>
|
|
246
|
+
</div>
|
|
247
|
+
`
|
|
248
|
+
})
|
|
249
|
+
.join('')
|
|
250
|
+
|
|
251
|
+
if (commitsOrCount.length > 50) {
|
|
252
|
+
const more = commitsOrCount.length - 50
|
|
253
|
+
contentEl.innerHTML += `<div style="color:#888; padding:8px 0">另外 ${more} 条已省略</div>`
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
contentEl.innerHTML = `<div style="font-size:14px;">无可展示数据</div>`
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 打开侧栏
|
|
260
|
+
sidebar.classList.add('show')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 关闭按钮绑定(只需运行一次)
|
|
264
|
+
;(function bindHourSidebarClose() {
|
|
265
|
+
const btn = document.getElementById('hourSidebarClose')
|
|
266
|
+
const sidebar = document.getElementById('hourDetailSidebar')
|
|
267
|
+
if (!btn || !sidebar) return
|
|
268
|
+
btn.addEventListener('click', () => sidebar.classList.remove('show'))
|
|
269
|
+
})()
|
|
270
|
+
|
|
271
|
+
// 简单的 HTML 转义,防止 XSS 与布局断裂
|
|
272
|
+
function escapeHtml(str = '') {
|
|
273
|
+
return String(str)
|
|
274
|
+
.replaceAll('&', '&')
|
|
275
|
+
.replaceAll('<', '<')
|
|
276
|
+
.replaceAll('>', '>')
|
|
277
|
+
.replaceAll('"', '"')
|
|
278
|
+
.replaceAll("'", ''')
|
|
279
|
+
}
|
|
280
|
+
|
|
120
281
|
function drawOutsideVsInside(stats) {
|
|
121
282
|
const el = document.getElementById('outsideVsInsideChart')
|
|
122
283
|
// eslint-disable-next-line no-undef
|
|
@@ -141,57 +302,303 @@ function drawOutsideVsInside(stats) {
|
|
|
141
302
|
}
|
|
142
303
|
|
|
143
304
|
function drawDailyTrend(commits) {
|
|
305
|
+
if (!Array.isArray(commits) || commits.length === 0) return null
|
|
306
|
+
|
|
307
|
+
// 聚合每日提交数量
|
|
144
308
|
const map = new Map()
|
|
145
309
|
commits.forEach((c) => {
|
|
146
310
|
const d = new Date(c.date).toISOString().slice(0, 10)
|
|
147
311
|
map.set(d, (map.get(d) || 0) + 1)
|
|
148
312
|
})
|
|
313
|
+
|
|
149
314
|
const labels = Array.from(map.keys()).sort()
|
|
150
315
|
const data = labels.map((l) => map.get(l))
|
|
316
|
+
|
|
151
317
|
const el = document.getElementById('dailyTrendChart')
|
|
152
318
|
// eslint-disable-next-line no-undef
|
|
153
319
|
const chart = echarts.init(el)
|
|
320
|
+
|
|
154
321
|
chart.setOption({
|
|
155
|
-
tooltip: {
|
|
322
|
+
tooltip: {
|
|
323
|
+
trigger: 'axis',
|
|
324
|
+
formatter: (params) => {
|
|
325
|
+
const p = params?.[0]
|
|
326
|
+
if (!p) return ''
|
|
327
|
+
|
|
328
|
+
const date = p.axisValue
|
|
329
|
+
const count = p.data
|
|
330
|
+
|
|
331
|
+
// 分级说明
|
|
332
|
+
let level = '🟢 正常(≤5 次)'
|
|
333
|
+
if (count > 5 && count < 10) level = '🟠 较高频(6–10 次)'
|
|
334
|
+
if (count >= 10) level = '🔴 高频(≥10 次)'
|
|
335
|
+
|
|
336
|
+
return `
|
|
337
|
+
<div style="font-size:13px; line-height:1.5;">
|
|
338
|
+
<b>${date}</b><br/>
|
|
339
|
+
提交次数:<b>${count}</b><br/>
|
|
340
|
+
等级:${level}
|
|
341
|
+
</div>
|
|
342
|
+
`
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
|
|
156
346
|
xAxis: { type: 'category', data: labels },
|
|
157
|
-
|
|
158
|
-
|
|
347
|
+
|
|
348
|
+
yAxis: { type: 'value', min: 0 },
|
|
349
|
+
|
|
350
|
+
series: [
|
|
351
|
+
{
|
|
352
|
+
type: 'line',
|
|
353
|
+
name: '每日提交',
|
|
354
|
+
data,
|
|
355
|
+
|
|
356
|
+
smooth: true,
|
|
357
|
+
|
|
358
|
+
// ⭐ area 渐变背景
|
|
359
|
+
areaStyle: {
|
|
360
|
+
opacity: 0.2
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
// ⭐ 背景区间(低 / 中 / 高频)
|
|
364
|
+
markArea: {
|
|
365
|
+
data: [
|
|
366
|
+
[
|
|
367
|
+
{ yAxis: 0 },
|
|
368
|
+
{ yAxis: 5, itemStyle: { color: 'rgba(76, 175, 80, 0.12)' } } // 绿
|
|
369
|
+
],
|
|
370
|
+
[
|
|
371
|
+
{ yAxis: 5 },
|
|
372
|
+
{ yAxis: 10, itemStyle: { color: 'rgba(251, 140, 0, 0.12)' } } // 橙
|
|
373
|
+
],
|
|
374
|
+
[
|
|
375
|
+
{ yAxis: 10 },
|
|
376
|
+
{ yAxis: 50, itemStyle: { color: 'rgba(211, 47, 47, 0.12)' } } // 红
|
|
377
|
+
]
|
|
378
|
+
]
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
// ⭐ 阈值线
|
|
382
|
+
markLine: {
|
|
383
|
+
symbol: ['none', 'arrow'],
|
|
384
|
+
data: [
|
|
385
|
+
{
|
|
386
|
+
yAxis: 5,
|
|
387
|
+
lineStyle: { color: '#fb8c00', width: 2, type: 'dashed' },
|
|
388
|
+
label: { formatter: '5 次', color: '#fb8c00' }
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
yAxis: 10,
|
|
392
|
+
lineStyle: { color: '#d32f2f', width: 2, type: 'dashed' },
|
|
393
|
+
label: { formatter: '10 次', color: '#d32f2f' }
|
|
394
|
+
}
|
|
395
|
+
]
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
]
|
|
159
399
|
})
|
|
400
|
+
|
|
160
401
|
return chart
|
|
161
402
|
}
|
|
162
403
|
|
|
163
404
|
function drawWeeklyTrend(weekly) {
|
|
405
|
+
if (!Array.isArray(weekly) || weekly.length === 0) return null
|
|
406
|
+
|
|
164
407
|
const labels = weekly.map((w) => w.period)
|
|
165
|
-
const dataRate = weekly.map((w) => +(w.outsideWorkRate * 100).toFixed(1))
|
|
408
|
+
const dataRate = weekly.map((w) => +(w.outsideWorkRate * 100).toFixed(1)) // %
|
|
166
409
|
const dataCount = weekly.map((w) => w.outsideWorkCount)
|
|
410
|
+
|
|
167
411
|
const el = document.getElementById('weeklyTrendChart')
|
|
168
412
|
// eslint-disable-next-line no-undef
|
|
169
413
|
const chart = echarts.init(el)
|
|
414
|
+
|
|
170
415
|
chart.setOption({
|
|
171
|
-
tooltip: {
|
|
416
|
+
tooltip: {
|
|
417
|
+
trigger: 'axis',
|
|
418
|
+
formatter: (params) => {
|
|
419
|
+
const rate = params.find((p) => p.seriesName.includes('%'))?.data
|
|
420
|
+
const count = params.find((p) => p.seriesName.includes('次数'))?.data
|
|
421
|
+
|
|
422
|
+
// 加班等级
|
|
423
|
+
let level = '🟢 健康(<10%)'
|
|
424
|
+
if (rate >= 10 && rate < 20) level = '🟠 中度(10–20%)'
|
|
425
|
+
if (rate >= 20) level = '🔴 严重(≥20%)'
|
|
426
|
+
|
|
427
|
+
return `
|
|
428
|
+
<div style="font-size:13px; line-height:1.5;">
|
|
429
|
+
<b>${params[0].axisValue}</b><br/>
|
|
430
|
+
加班占比:<b>${rate}%</b><br/>
|
|
431
|
+
加班次数:${count} 次<br/>
|
|
432
|
+
等级:${level}
|
|
433
|
+
</div>
|
|
434
|
+
`
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
legend: {
|
|
439
|
+
top: 10
|
|
440
|
+
},
|
|
441
|
+
|
|
172
442
|
xAxis: { type: 'category', data: labels },
|
|
173
|
-
|
|
443
|
+
|
|
444
|
+
yAxis: [
|
|
445
|
+
{ type: 'value', min: 0, max: 100, name: '占比(%)' },
|
|
446
|
+
{ type: 'value', name: '次数', min: 0 }
|
|
447
|
+
],
|
|
448
|
+
|
|
174
449
|
series: [
|
|
175
|
-
{
|
|
176
|
-
|
|
450
|
+
{
|
|
451
|
+
type: 'line',
|
|
452
|
+
name: '加班占比(%)',
|
|
453
|
+
data: dataRate,
|
|
454
|
+
|
|
455
|
+
// ⭐ 区间背景(与 monthly/daily 对齐)
|
|
456
|
+
markArea: {
|
|
457
|
+
data: [
|
|
458
|
+
[
|
|
459
|
+
{ yAxis: 0 },
|
|
460
|
+
{ yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } } // 绿色
|
|
461
|
+
],
|
|
462
|
+
[
|
|
463
|
+
{ yAxis: 10 },
|
|
464
|
+
{ yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } } // 橙色
|
|
465
|
+
],
|
|
466
|
+
[
|
|
467
|
+
{ yAxis: 20 },
|
|
468
|
+
{ yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } } // 红色
|
|
469
|
+
]
|
|
470
|
+
]
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
// ⭐ 阈值线
|
|
474
|
+
markLine: {
|
|
475
|
+
symbol: ['none', 'arrow'],
|
|
476
|
+
data: [
|
|
477
|
+
{
|
|
478
|
+
yAxis: 10,
|
|
479
|
+
lineStyle: { color: '#fb8c00', width: 2, type: 'dashed' },
|
|
480
|
+
label: { formatter: '10%', color: '#fb8c00' }
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
yAxis: 20,
|
|
484
|
+
lineStyle: { color: '#d32f2f', width: 2, type: 'dashed' },
|
|
485
|
+
label: { formatter: '20%', color: '#d32f2f' }
|
|
486
|
+
}
|
|
487
|
+
]
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
{
|
|
492
|
+
type: 'line',
|
|
493
|
+
name: '加班次数',
|
|
494
|
+
data: dataCount,
|
|
495
|
+
yAxisIndex: 1,
|
|
496
|
+
|
|
497
|
+
// 次数线使用默认蓝色,避免干扰等级颜色区间
|
|
498
|
+
smooth: true
|
|
499
|
+
}
|
|
177
500
|
]
|
|
178
501
|
})
|
|
502
|
+
|
|
179
503
|
return chart
|
|
180
504
|
}
|
|
181
505
|
|
|
182
506
|
function drawMonthlyTrend(monthly) {
|
|
183
507
|
if (!Array.isArray(monthly) || monthly.length === 0) return null
|
|
508
|
+
|
|
184
509
|
const labels = monthly.map((m) => m.period)
|
|
185
|
-
const dataRate = monthly.map((m) => +(m.outsideWorkRate * 100).toFixed(1))
|
|
510
|
+
const dataRate = monthly.map((m) => +(m.outsideWorkRate * 100).toFixed(1)) // 0–100%
|
|
511
|
+
|
|
186
512
|
const el = document.getElementById('monthlyTrendChart')
|
|
187
513
|
// eslint-disable-next-line no-undef
|
|
188
514
|
const chart = echarts.init(el)
|
|
515
|
+
|
|
189
516
|
chart.setOption({
|
|
190
|
-
tooltip: {
|
|
517
|
+
tooltip: {
|
|
518
|
+
trigger: 'axis',
|
|
519
|
+
formatter: (params) => {
|
|
520
|
+
const p = params[0]
|
|
521
|
+
if (!p) return ''
|
|
522
|
+
|
|
523
|
+
const rate = p.data
|
|
524
|
+
let level = '🟢 健康(<10%)'
|
|
525
|
+
if (rate >= 10 && rate < 20) level = '🟠 中度(10–20%)'
|
|
526
|
+
if (rate >= 20) level = '🔴 严重(≥20%)'
|
|
527
|
+
|
|
528
|
+
return `
|
|
529
|
+
<div style="font-size:13px; line-height:1.5">
|
|
530
|
+
<b>${p.axisValue}</b><br/>
|
|
531
|
+
加班占比:<b>${rate}%</b><br/>
|
|
532
|
+
加班等级:${level}
|
|
533
|
+
</div>
|
|
534
|
+
`
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
|
|
191
538
|
xAxis: { type: 'category', data: labels },
|
|
192
539
|
yAxis: { type: 'value', min: 0, max: 100 },
|
|
193
|
-
|
|
540
|
+
|
|
541
|
+
series: [
|
|
542
|
+
{
|
|
543
|
+
type: 'line',
|
|
544
|
+
name: '加班占比(%)',
|
|
545
|
+
data: dataRate,
|
|
546
|
+
|
|
547
|
+
// ⭐ 区间背景(可配置)
|
|
548
|
+
markArea: {
|
|
549
|
+
data: [
|
|
550
|
+
// <10% 绿色轻度
|
|
551
|
+
[
|
|
552
|
+
{ yAxis: 0 },
|
|
553
|
+
{ yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } }
|
|
554
|
+
],
|
|
555
|
+
// 10–20% 橙色中度
|
|
556
|
+
[
|
|
557
|
+
{ yAxis: 10 },
|
|
558
|
+
{ yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } }
|
|
559
|
+
],
|
|
560
|
+
// ≥20% 红色严重
|
|
561
|
+
[
|
|
562
|
+
{ yAxis: 20 },
|
|
563
|
+
{ yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } }
|
|
564
|
+
]
|
|
565
|
+
]
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
// ⭐ 阈值线(同每日图风格)
|
|
569
|
+
markLine: {
|
|
570
|
+
symbol: ['none', 'arrow'],
|
|
571
|
+
data: [
|
|
572
|
+
{
|
|
573
|
+
yAxis: 10,
|
|
574
|
+
lineStyle: {
|
|
575
|
+
color: '#fb8c00',
|
|
576
|
+
width: 2,
|
|
577
|
+
type: 'dashed'
|
|
578
|
+
},
|
|
579
|
+
label: {
|
|
580
|
+
formatter: '10%',
|
|
581
|
+
color: '#fb8c00'
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
yAxis: 20,
|
|
586
|
+
lineStyle: {
|
|
587
|
+
color: '#d32f2f',
|
|
588
|
+
width: 2,
|
|
589
|
+
type: 'dashed'
|
|
590
|
+
},
|
|
591
|
+
label: {
|
|
592
|
+
formatter: '20%',
|
|
593
|
+
color: '#d32f2f'
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
]
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
]
|
|
194
600
|
})
|
|
601
|
+
|
|
195
602
|
return chart
|
|
196
603
|
}
|
|
197
604
|
|
|
@@ -211,6 +618,7 @@ function drawLatestHourDaily(latestByDay) {
|
|
|
211
618
|
value: v,
|
|
212
619
|
itemStyle: {
|
|
213
620
|
color:
|
|
621
|
+
// eslint-disable-next-line no-nested-ternary
|
|
214
622
|
v >= 20
|
|
215
623
|
? '#d32f2f' // 红
|
|
216
624
|
: v >= 19
|
|
@@ -224,6 +632,7 @@ function drawLatestHourDaily(latestByDay) {
|
|
|
224
632
|
const maxV = numericValues.length > 0 ? Math.max(...numericValues) : 0
|
|
225
633
|
|
|
226
634
|
const el = document.getElementById('latestHourDailyChart')
|
|
635
|
+
// eslint-disable-next-line no-undef
|
|
227
636
|
const chart = echarts.init(el)
|
|
228
637
|
|
|
229
638
|
chart.setOption({
|
|
@@ -233,8 +642,31 @@ function drawLatestHourDaily(latestByDay) {
|
|
|
233
642
|
const p = Array.isArray(params) ? params[0] : params
|
|
234
643
|
const v = p?.value != null ? Number(p.value) : null
|
|
235
644
|
const endH = window.__overtimeEndHour || 18
|
|
236
|
-
|
|
237
|
-
|
|
645
|
+
|
|
646
|
+
if (v == null) {
|
|
647
|
+
return `
|
|
648
|
+
<div style="font-size:13px; line-height:1.5">
|
|
649
|
+
<b>${p.axisValue}</b><br/>
|
|
650
|
+
无数据
|
|
651
|
+
</div>
|
|
652
|
+
`
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const overtime = Math.max(0, v - endH)
|
|
656
|
+
const overtimeText = overtime.toFixed(2)
|
|
657
|
+
|
|
658
|
+
let level = '🟢 正常(无明显加班)'
|
|
659
|
+
if (overtime >= 1 && overtime < 2) level = '🟠 中度加班(1–2h)'
|
|
660
|
+
if (overtime >= 2) level = '🔴 严重加班(≥2h)'
|
|
661
|
+
|
|
662
|
+
return `
|
|
663
|
+
<div style="font-size:13px; line-height:1.5">
|
|
664
|
+
<b>${p.axisValue}</b><br/>
|
|
665
|
+
最晚提交时间:<b>${v.toFixed(2)} 点</b><br/>
|
|
666
|
+
超出下班:<b>${overtimeText} 小时</b><br/>
|
|
667
|
+
加班等级:${level}
|
|
668
|
+
</div>
|
|
669
|
+
`
|
|
238
670
|
}
|
|
239
671
|
},
|
|
240
672
|
xAxis: { type: 'category', data: labels },
|
|
@@ -287,24 +719,60 @@ function drawLatestHourDaily(latestByDay) {
|
|
|
287
719
|
}
|
|
288
720
|
|
|
289
721
|
function drawDailySeverity(latestByDay) {
|
|
290
|
-
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
|
|
722
|
+
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
|
|
291
723
|
|
|
292
|
-
const labels = latestByDay.map((d) => d.date)
|
|
293
|
-
const endH = window.__overtimeEndHour || 18
|
|
724
|
+
const labels = latestByDay.map((d) => d.date)
|
|
725
|
+
const endH = window.__overtimeEndHour || 18
|
|
294
726
|
|
|
295
727
|
const raw = latestByDay.map((d) =>
|
|
296
728
|
typeof d.latestHourNormalized === 'number'
|
|
297
729
|
? d.latestHourNormalized
|
|
298
730
|
: (d.latestHour ?? null)
|
|
299
|
-
)
|
|
731
|
+
)
|
|
300
732
|
|
|
301
|
-
const sev = raw.map((v) => (v == null ? null : Math.max(0, Number(v) - endH)))
|
|
733
|
+
const sev = raw.map((v) => (v == null ? null : Math.max(0, Number(v) - endH)))
|
|
302
734
|
|
|
303
|
-
const el = document.getElementById('dailySeverityChart')
|
|
304
|
-
|
|
735
|
+
const el = document.getElementById('dailySeverityChart')
|
|
736
|
+
// eslint-disable-next-line no-undef
|
|
737
|
+
const chart = echarts.init(el)
|
|
305
738
|
|
|
306
739
|
chart.setOption({
|
|
307
|
-
tooltip: {
|
|
740
|
+
tooltip: {
|
|
741
|
+
trigger: 'axis',
|
|
742
|
+
formatter: (params) => {
|
|
743
|
+
const p = params[0]
|
|
744
|
+
if (!p) return ''
|
|
745
|
+
const date = p.axisValue
|
|
746
|
+
const overtime = p.data
|
|
747
|
+
const rawHour = raw[p.dataIndex] // 原始 latestHour 或 latestHourNormalized
|
|
748
|
+
|
|
749
|
+
if (overtime == null) {
|
|
750
|
+
return `
|
|
751
|
+
<div style="font-size:13px;">
|
|
752
|
+
<b>${date}</b><br/>
|
|
753
|
+
无数据
|
|
754
|
+
</div>
|
|
755
|
+
`
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return `
|
|
759
|
+
<div style="font-size:13px;">
|
|
760
|
+
<b>${date}</b><br/>
|
|
761
|
+
下班后:<b>${overtime.toFixed(2)} 小时</b><br/>
|
|
762
|
+
原始最晚提交:${rawHour != null ? `${rawHour.toFixed(2)} 点` : '无'}<br/>
|
|
763
|
+
加班等级:${
|
|
764
|
+
// eslint-disable-next-line no-nested-ternary
|
|
765
|
+
overtime < 1
|
|
766
|
+
? '🟢 0–1 小时(轻度)'
|
|
767
|
+
: overtime < 2
|
|
768
|
+
? '🟠 1–2 小时(中度)'
|
|
769
|
+
: '🔴 ≥2 小时(严重)'
|
|
770
|
+
}
|
|
771
|
+
</div>
|
|
772
|
+
`
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
|
|
308
776
|
xAxis: { type: 'category', data: labels },
|
|
309
777
|
yAxis: { type: 'value', min: 0 },
|
|
310
778
|
|
|
@@ -318,10 +786,7 @@ function drawDailySeverity(latestByDay) {
|
|
|
318
786
|
markArea: {
|
|
319
787
|
data: [
|
|
320
788
|
// 0–1h:透明
|
|
321
|
-
[
|
|
322
|
-
{ yAxis: 0 },
|
|
323
|
-
{ yAxis: 1, itemStyle: { color: 'rgba(0,0,0,0)' } }
|
|
324
|
-
],
|
|
789
|
+
[{ yAxis: 0 }, { yAxis: 1, itemStyle: { color: 'rgba(0,0,0,0)' } }],
|
|
325
790
|
// 1–2h:半透明橙色
|
|
326
791
|
[
|
|
327
792
|
{ yAxis: 1 },
|
|
@@ -361,11 +826,197 @@ function drawDailySeverity(latestByDay) {
|
|
|
361
826
|
}
|
|
362
827
|
}
|
|
363
828
|
]
|
|
364
|
-
})
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
return chart
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* 绘制每日趋势(带加班严重度背景区间)并自动分析最累的日期
|
|
836
|
+
* @param {Array} commits - 原始提交记录(包含 c.date)
|
|
837
|
+
* @param {Function} onDayClick - 用户点击某一天时的回调 (date, count) => void
|
|
838
|
+
*/
|
|
839
|
+
/**
|
|
840
|
+
* 绘制每日趋势(含严重度背景区间、最累标记、tooltip 明细)
|
|
841
|
+
*/
|
|
842
|
+
function drawDailyTrendSeverity(commits, weekly, onDayClick) {
|
|
843
|
+
// ---------- 1. 聚合每日数据 ----------
|
|
844
|
+
const dayMap = new Map()
|
|
845
|
+
const dayCommitsDetail = {}
|
|
846
|
+
|
|
847
|
+
commits.forEach((c) => {
|
|
848
|
+
const d = new Date(c.date).toISOString().slice(0, 10)
|
|
849
|
+
|
|
850
|
+
// 数量统计
|
|
851
|
+
dayMap.set(d, (dayMap.get(d) || 0) + 1)
|
|
852
|
+
|
|
853
|
+
// 详细信息统计(用于 tooltip 显示)
|
|
854
|
+
if (!dayCommitsDetail[d]) dayCommitsDetail[d] = []
|
|
855
|
+
dayCommitsDetail[d].push({
|
|
856
|
+
author: c.author,
|
|
857
|
+
time: c.date,
|
|
858
|
+
msg: c.message
|
|
859
|
+
})
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
const labels = Array.from(dayMap.keys()).sort()
|
|
863
|
+
const data = labels.map((l) => dayMap.get(l))
|
|
864
|
+
|
|
865
|
+
// ---------- 2. 自动分析「最累的一天」 ----------
|
|
866
|
+
const maxDailyCount = Math.max(...data)
|
|
867
|
+
const maxDailyIndex = data.indexOf(maxDailyCount)
|
|
868
|
+
const mostTiredDay = labels[maxDailyIndex]
|
|
869
|
+
|
|
870
|
+
document.getElementById('mostTiredDay').innerHTML =
|
|
871
|
+
`🔥 最累的一天:<b>${mostTiredDay}</b>(${maxDailyCount} 次提交)`
|
|
872
|
+
|
|
873
|
+
// ---------- 3. 自动分析「最累的一周」 ----------
|
|
874
|
+
let maxWeek = null
|
|
875
|
+
if (Array.isArray(weekly)) {
|
|
876
|
+
maxWeek = weekly.reduce((a, b) =>
|
|
877
|
+
a.outsideWorkCount > b.outsideWorkCount ? a : b
|
|
878
|
+
)
|
|
879
|
+
if (maxWeek) {
|
|
880
|
+
document.getElementById('mostTiredWeek').innerHTML =
|
|
881
|
+
`🔥 最累的一周:<b>${maxWeek.period}</b>(${maxWeek.outsideWorkCount} 次加班)`
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ---------- 4. 自动分析「最累的月份」 ----------
|
|
886
|
+
const monthMap = new Map()
|
|
887
|
+
commits.forEach((c) => {
|
|
888
|
+
const d = new Date(c.date)
|
|
889
|
+
const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
890
|
+
monthMap.set(ym, (monthMap.get(ym) || 0) + 1)
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
const mostTiredMonth = Array.from(monthMap.entries()).sort(
|
|
894
|
+
(a, b) => b[1] - a[1]
|
|
895
|
+
)[0]
|
|
896
|
+
|
|
897
|
+
document.getElementById('mostTiredMonth').innerHTML =
|
|
898
|
+
`🔥 最累的月份:<b>${mostTiredMonth[0]}</b>(${mostTiredMonth[1]} 次提交)`
|
|
899
|
+
|
|
900
|
+
// ---------- 5. 背景严重度区块 ----------
|
|
901
|
+
const markArea = {
|
|
902
|
+
silent: true,
|
|
903
|
+
itemStyle: { opacity: 0.15 },
|
|
904
|
+
data: [
|
|
905
|
+
[{ name: '0–1 小时', yAxis: 0 }, { yAxis: 1 }],
|
|
906
|
+
[
|
|
907
|
+
{ name: '1–2 小时', yAxis: 1 },
|
|
908
|
+
{ yAxis: 2, itemStyle: { color: 'orange', opacity: 0.25 } }
|
|
909
|
+
],
|
|
910
|
+
[
|
|
911
|
+
{ name: '2 小时以上', yAxis: 2 },
|
|
912
|
+
{ yAxis: 999, itemStyle: { color: 'red', opacity: 0.25 } }
|
|
913
|
+
]
|
|
914
|
+
]
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ---------- 6. 构造 tooltip ----------
|
|
918
|
+
const tooltipFormatter = (params) => {
|
|
919
|
+
const date = params?.[0].name
|
|
920
|
+
const count = params?.[0].value
|
|
921
|
+
const details = dayCommitsDetail[date] || []
|
|
922
|
+
|
|
923
|
+
let html = `📅 <b>${date}</b><br/>提交次数:${count}<br/><br/>`
|
|
924
|
+
|
|
925
|
+
details.slice(0, 5).forEach((d) => {
|
|
926
|
+
html += `👤 ${d.author}<br/>🕒 ${d.time}<br/>💬 ${d.msg}<br/><br/>`
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
if (details.length > 5) {
|
|
930
|
+
html += `(其余 ${details.length - 5} 条已省略)`
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return html
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ---------- 7. 绘图 ----------
|
|
937
|
+
const el = document.getElementById('dailyTrendChartDog')
|
|
938
|
+
const chart = echarts.init(el)
|
|
939
|
+
|
|
940
|
+
chart.setOption({
|
|
941
|
+
tooltip: {
|
|
942
|
+
trigger: 'axis',
|
|
943
|
+
formatter: tooltipFormatter,
|
|
944
|
+
axisPointer: { type: 'shadow' }
|
|
945
|
+
},
|
|
946
|
+
xAxis: { type: 'category', data: labels },
|
|
947
|
+
yAxis: { type: 'value', min: 0 },
|
|
948
|
+
series: [
|
|
949
|
+
{
|
|
950
|
+
type: 'line',
|
|
951
|
+
name: '每日提交',
|
|
952
|
+
data,
|
|
953
|
+
areaStyle: {},
|
|
954
|
+
markArea,
|
|
955
|
+
markPoint: {
|
|
956
|
+
data: [
|
|
957
|
+
{
|
|
958
|
+
name: '最累的一天',
|
|
959
|
+
coord: [mostTiredDay, maxDailyCount],
|
|
960
|
+
value: maxDailyCount,
|
|
961
|
+
symbolSize: 70,
|
|
962
|
+
itemStyle: { color: '#ff4d4f' },
|
|
963
|
+
label: { formatter: '🔥 最累' }
|
|
964
|
+
}
|
|
965
|
+
]
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
]
|
|
969
|
+
})
|
|
365
970
|
|
|
366
|
-
|
|
971
|
+
// ---------- 8. 点击事件 ----------
|
|
972
|
+
if (typeof onDayClick === 'function') {
|
|
973
|
+
chart.on('click', (params) => {
|
|
974
|
+
if (params.componentType === 'series') {
|
|
975
|
+
const date = labels[params.dataIndex]
|
|
976
|
+
const count = data[params.dataIndex]
|
|
977
|
+
onDayClick(date, count, dayCommitsDetail[date])
|
|
978
|
+
}
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return {
|
|
983
|
+
chart,
|
|
984
|
+
analysis: {
|
|
985
|
+
mostTiredDay,
|
|
986
|
+
mostTiredMonth,
|
|
987
|
+
mostTiredWeek: maxWeek
|
|
988
|
+
}
|
|
989
|
+
}
|
|
367
990
|
}
|
|
368
991
|
|
|
992
|
+
function showDayDetailSidebar(date, count, commits) {
|
|
993
|
+
const sidebar = document.getElementById('dayDetailSidebar')
|
|
994
|
+
const title = document.getElementById('sidebarTitle')
|
|
995
|
+
const content = document.getElementById('sidebarContent')
|
|
996
|
+
|
|
997
|
+
title.innerHTML = `📅 ${date}(${count} 次提交)`
|
|
998
|
+
|
|
999
|
+
// 渲染详情
|
|
1000
|
+
content.innerHTML = commits
|
|
1001
|
+
.map(
|
|
1002
|
+
(c) => `
|
|
1003
|
+
<div style="margin-bottom:12px;">
|
|
1004
|
+
<div>👤 <b>${c.author}</b></div>
|
|
1005
|
+
<div>🕒 ${c.time}</div>
|
|
1006
|
+
<div>💬 ${c.msg}</div>
|
|
1007
|
+
</div>
|
|
1008
|
+
<hr/>
|
|
1009
|
+
`
|
|
1010
|
+
)
|
|
1011
|
+
.join('')
|
|
1012
|
+
|
|
1013
|
+
sidebar.classList.add('show')
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// 关闭按钮
|
|
1017
|
+
document.getElementById('sidebarClose').onclick = () => {
|
|
1018
|
+
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
1019
|
+
}
|
|
369
1020
|
|
|
370
1021
|
function renderKpi(stats) {
|
|
371
1022
|
const el = document.getElementById('kpiContent')
|
|
@@ -378,13 +1029,27 @@ function renderKpi(stats) {
|
|
|
378
1029
|
(latestOut ? new Date(latestOut.date).getHours() : null)
|
|
379
1030
|
const cutoff = window.__overnightCutoff ?? 6
|
|
380
1031
|
const html = [
|
|
381
|
-
`<div>最晚一次提交时间:${latest ? formatDate(latest.date) : '-'}${typeof latestHour === 'number' ? `(${String(latestHour).padStart(2, '0')}:00)` : ''}</div>`,
|
|
382
|
-
`<div
|
|
1032
|
+
`<div>最晚一次提交时间:${latest ? formatDate(latest.date) : '-'}${typeof latestHour === 'number' ? `(${String(latestHour).padStart(2, '0')}:00)` : ''} <div class="author">${latest.author}</div> <div> ${latest.message} <div></div>`,
|
|
1033
|
+
`<div class="hr"></div>`,
|
|
1034
|
+
`<div>加班最晚一次提交时间:${latestOut ? formatDate(latestOut.date) : '-'}${typeof latestOutHour === 'number' ? `(${String(latestOutHour).padStart(2, '0')}:00)` : ''} <div class="author">${latestOut.author}</div> <div>${latestOut.message}</div> </div>`,
|
|
1035
|
+
`<div class="hr"></div>`,
|
|
383
1036
|
`<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div>`
|
|
384
1037
|
].join('')
|
|
385
1038
|
el.innerHTML = html
|
|
386
1039
|
}
|
|
387
1040
|
|
|
1041
|
+
// 1) 按小时分组(例:commits 为原始提交数组)
|
|
1042
|
+
function groupCommitsByHour(commits) {
|
|
1043
|
+
const byHour = Array.from({ length: 24 }, () => [])
|
|
1044
|
+
commits.forEach((c) => {
|
|
1045
|
+
// 解析 commit 的本地小时(考虑时区已有 '+0800' 等)
|
|
1046
|
+
const d = new Date(c.date)
|
|
1047
|
+
const h = d.getHours() // 若数据已为 UTC,请按需求调整
|
|
1048
|
+
byHour[h].push(c)
|
|
1049
|
+
})
|
|
1050
|
+
return byHour
|
|
1051
|
+
}
|
|
1052
|
+
|
|
388
1053
|
;(async function main() {
|
|
389
1054
|
const { commits, stats, weekly, monthly, latestByDay, config } =
|
|
390
1055
|
await loadData()
|
|
@@ -399,12 +1064,21 @@ function renderKpi(stats) {
|
|
|
399
1064
|
initTableControls()
|
|
400
1065
|
updatePager()
|
|
401
1066
|
renderCommitsTablePage()
|
|
402
|
-
|
|
1067
|
+
// 使用举例
|
|
1068
|
+
const hourCommitsDetail = groupCommitsByHour(commits)
|
|
1069
|
+
|
|
1070
|
+
drawHourlyOvertime(stats, (hour, count) => {
|
|
1071
|
+
// 将 commit 列表传给侧栏(若没有详情,则传空数组)
|
|
1072
|
+
showSideBarForHour(hour, hourCommitsDetail[hour] || [])
|
|
1073
|
+
})
|
|
403
1074
|
drawOutsideVsInside(stats)
|
|
404
1075
|
drawDailyTrend(commits)
|
|
405
1076
|
drawWeeklyTrend(weekly)
|
|
406
1077
|
drawMonthlyTrend(monthly)
|
|
407
1078
|
drawLatestHourDaily(latestByDay)
|
|
408
1079
|
drawDailySeverity(latestByDay)
|
|
1080
|
+
const daily = drawDailyTrendSeverity(commits, weekly, showDayDetailSidebar)
|
|
1081
|
+
|
|
1082
|
+
console.log('最累的一天:', daily.analysis.mostTiredDay)
|
|
409
1083
|
renderKpi(stats)
|
|
410
1084
|
})()
|
package/web/index.html
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
<h2>按日提交趋势</h2>
|
|
33
33
|
<div id="dailyTrendChart" class="echart"></div>
|
|
34
34
|
</div>
|
|
35
|
+
|
|
35
36
|
<div class="chart-card">
|
|
36
37
|
<h2>每周趋势(加班占比)</h2>
|
|
37
38
|
<div id="weeklyTrendChart" class="echart"></div>
|
|
@@ -48,12 +49,23 @@
|
|
|
48
49
|
<h2>每日超过下班的小时数</h2>
|
|
49
50
|
<div id="dailySeverityChart" class="echart"></div>
|
|
50
51
|
</div>
|
|
52
|
+
<div class="chart-card">
|
|
53
|
+
<h2>按日提交趋势</h2>
|
|
54
|
+
<div id="dailyTrendChartDog" class="echart"></div>
|
|
55
|
+
</div>
|
|
56
|
+
<div id="mostTiredDay"></div>
|
|
57
|
+
<div id="mostTiredWeek"></div>
|
|
58
|
+
<div id="mostTiredMonth"></div>
|
|
51
59
|
</section>
|
|
52
60
|
|
|
53
61
|
<section class="table-card">
|
|
54
62
|
<h2>提交清单</h2>
|
|
55
63
|
<div id="tableControls">
|
|
56
|
-
<input
|
|
64
|
+
<input
|
|
65
|
+
id="searchInput"
|
|
66
|
+
type="search"
|
|
67
|
+
placeholder="搜索作者/信息/Hash"
|
|
68
|
+
/>
|
|
57
69
|
<label for="pageSize">每页显示</label>
|
|
58
70
|
<select id="pageSize">
|
|
59
71
|
<option value="10">10</option>
|
|
@@ -86,6 +98,26 @@
|
|
|
86
98
|
<code>/data/</code>.</small
|
|
87
99
|
>
|
|
88
100
|
</footer>
|
|
101
|
+
<!-- 右侧滑出的详情侧栏 -->
|
|
102
|
+
<div id="dayDetailSidebar" class="sidebar">
|
|
103
|
+
<div class="sidebar-header">
|
|
104
|
+
<span id="sidebarTitle"></span>
|
|
105
|
+
<button id="sidebarClose">×</button>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div id="sidebarContent" class="sidebar-content"></div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<!-- 小时详情侧栏(独立) -->
|
|
112
|
+
<div id="hourDetailSidebar" class="sidebar">
|
|
113
|
+
<div class="sidebar-header">
|
|
114
|
+
<span id="hourSidebarTitle"> </span>
|
|
115
|
+
<button id="hourSidebarClose">×</button>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div id="hourSidebarContent" class="sidebar-content"></div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
89
121
|
<script type="module" src="/app.js"></script>
|
|
90
122
|
</body>
|
|
91
123
|
</html>
|
package/web/static/style.css
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/* 全局 -------------------------------------------------- */
|
|
2
2
|
body {
|
|
3
|
-
font-family:
|
|
3
|
+
font-family:
|
|
4
|
+
'Roboto',
|
|
5
|
+
-apple-system,
|
|
6
|
+
BlinkMacSystemFont,
|
|
7
|
+
'Segoe UI',
|
|
8
|
+
Arial;
|
|
4
9
|
margin: 0;
|
|
5
10
|
padding: 0;
|
|
6
11
|
background: #f5f5f5;
|
|
@@ -14,7 +19,7 @@ header {
|
|
|
14
19
|
padding: 16px 24px;
|
|
15
20
|
display: flex;
|
|
16
21
|
align-items: center;
|
|
17
|
-
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
22
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
header h1 {
|
|
@@ -39,7 +44,7 @@ main {
|
|
|
39
44
|
background: white;
|
|
40
45
|
border-radius: 12px;
|
|
41
46
|
padding: 20px;
|
|
42
|
-
box-shadow: 0 3px 8px rgba(0,0,0,0.06);
|
|
47
|
+
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.06);
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
.chart-card h2,
|
|
@@ -51,9 +56,22 @@ main {
|
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
/* 图表网格 -------------------------------------------------- */
|
|
54
|
-
#charts {
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
#charts {
|
|
60
|
+
display: grid;
|
|
61
|
+
grid-template-columns: repeat(auto-fit, minmax(640px, 1fr));
|
|
62
|
+
gap: 16px;
|
|
63
|
+
}
|
|
64
|
+
#chartsHalf {
|
|
65
|
+
display: grid;
|
|
66
|
+
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
67
|
+
gap: 16px;
|
|
68
|
+
}
|
|
69
|
+
.chart-card {
|
|
70
|
+
background: white;
|
|
71
|
+
border-radius: 8px;
|
|
72
|
+
padding: 12px;
|
|
73
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
|
74
|
+
}
|
|
57
75
|
.echart {
|
|
58
76
|
width: 100%;
|
|
59
77
|
height: 340px;
|
|
@@ -89,19 +107,21 @@ td {
|
|
|
89
107
|
}
|
|
90
108
|
|
|
91
109
|
/* 输入框 — MUI Filled 风格 */
|
|
92
|
-
#tableControls input[type=
|
|
110
|
+
#tableControls input[type='search'] {
|
|
93
111
|
flex: 1;
|
|
94
112
|
min-width: 240px;
|
|
95
113
|
padding: 10px 12px;
|
|
96
114
|
border: 1px solid #ccc;
|
|
97
115
|
background: #fafafa;
|
|
98
116
|
border-radius: 8px;
|
|
99
|
-
transition:
|
|
117
|
+
transition:
|
|
118
|
+
border-color 0.2s,
|
|
119
|
+
box-shadow 0.2s;
|
|
100
120
|
}
|
|
101
121
|
|
|
102
|
-
#tableControls input[type=
|
|
122
|
+
#tableControls input[type='search']:focus {
|
|
103
123
|
border-color: #1976d2;
|
|
104
|
-
box-shadow: 0 0 0 3px rgba(25,118,210,0.15);
|
|
124
|
+
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.15);
|
|
105
125
|
outline: none;
|
|
106
126
|
}
|
|
107
127
|
|
|
@@ -116,24 +136,26 @@ td {
|
|
|
116
136
|
|
|
117
137
|
#tableControls select:focus {
|
|
118
138
|
border-color: #1976d2;
|
|
119
|
-
box-shadow: 0 0 0 3px rgba(25,118,210,0.15);
|
|
139
|
+
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.15);
|
|
120
140
|
outline: none;
|
|
121
141
|
}
|
|
122
142
|
|
|
123
143
|
/* 按钮 — MUI Button 风格 */
|
|
124
144
|
.pager button {
|
|
125
145
|
padding: 8px 14px;
|
|
126
|
-
border: 1px solid #
|
|
127
|
-
background: #
|
|
146
|
+
border: 1px solid #00a76f;
|
|
147
|
+
background: #00a76f;
|
|
128
148
|
color: white;
|
|
129
149
|
border-radius: 8px;
|
|
130
150
|
cursor: pointer;
|
|
131
|
-
transition:
|
|
151
|
+
transition:
|
|
152
|
+
background 0.2s,
|
|
153
|
+
box-shadow 0.2s;
|
|
132
154
|
}
|
|
133
155
|
|
|
134
156
|
.pager button:hover {
|
|
135
157
|
background: #007867;
|
|
136
|
-
box-shadow: 0 2px 5px rgba(0,0,0,0.15);
|
|
158
|
+
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
|
|
137
159
|
}
|
|
138
160
|
|
|
139
161
|
.pager button:disabled {
|
|
@@ -156,6 +178,74 @@ td {
|
|
|
156
178
|
color: #555;
|
|
157
179
|
}
|
|
158
180
|
|
|
181
|
+
.hr {
|
|
182
|
+
border: none;
|
|
183
|
+
border-top: 1px solid #eee;
|
|
184
|
+
margin: 12px 0;
|
|
185
|
+
}
|
|
186
|
+
.author {
|
|
187
|
+
font-weight: bold;
|
|
188
|
+
margin-top: 4px;
|
|
189
|
+
margin-bottom: 8px;
|
|
190
|
+
color: #00a76f;
|
|
191
|
+
}
|
|
192
|
+
.sidebar {
|
|
193
|
+
position: fixed;
|
|
194
|
+
top: 0;
|
|
195
|
+
right: -400px;
|
|
196
|
+
width: 400px;
|
|
197
|
+
height: 100%;
|
|
198
|
+
background: #fff;
|
|
199
|
+
box-shadow: -2px 0 6px rgba(0, 0, 0, 0.15);
|
|
200
|
+
transition: right 0.35s ease;
|
|
201
|
+
z-index: 9999;
|
|
202
|
+
padding: 16px;
|
|
203
|
+
display: flex;
|
|
204
|
+
flex-direction: column;
|
|
205
|
+
box-sizing: border-box;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.sidebar.show {
|
|
209
|
+
right: 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.sidebar-header {
|
|
213
|
+
display: flex;
|
|
214
|
+
justify-content: space-between;
|
|
215
|
+
align-items: center;
|
|
216
|
+
font-size: 18px;
|
|
217
|
+
margin-bottom: 16px;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.sidebar-header button {
|
|
221
|
+
background: none;
|
|
222
|
+
border: none;
|
|
223
|
+
font-size: 24px;
|
|
224
|
+
cursor: pointer;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.sidebar-content {
|
|
228
|
+
overflow-y: auto;
|
|
229
|
+
font-size: 14px;
|
|
230
|
+
}
|
|
231
|
+
/* 小块提交记录样式 */
|
|
232
|
+
.hour-commit {
|
|
233
|
+
padding: 10px 8px;
|
|
234
|
+
border-radius: 6px;
|
|
235
|
+
background: #fafafa;
|
|
236
|
+
margin-bottom: 10px;
|
|
237
|
+
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
|
238
|
+
}
|
|
239
|
+
.hour-commit .meta {
|
|
240
|
+
color: #666;
|
|
241
|
+
font-size: 12px;
|
|
242
|
+
margin-bottom: 6px;
|
|
243
|
+
}
|
|
244
|
+
.hour-commit .msg {
|
|
245
|
+
font-weight: 500;
|
|
246
|
+
word-break: break-word;
|
|
247
|
+
}
|
|
248
|
+
|
|
159
249
|
/* 底部 -------------------------------------------------- */
|
|
160
250
|
footer {
|
|
161
251
|
text-align: center;
|