wukong-gitlog-cli 1.0.17 → 1.0.19
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 +17 -0
- package/package.json +1 -1
- package/web/app.js +749 -48
- package/web/index.html +36 -7
- package/web/static/style.css +76 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
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.19](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.18...v1.0.19) (2025-12-05)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* 🎸 latestMonthlyRiskSummary ([e8a442e](https://github.com/tomatobybike/wukong-gitlog-cli/commit/e8a442e8333ac303815e7eaaf3aa571e7e976bd6))
|
|
11
|
+
* 🎸 monthlyDurationRiskSummary ([d853876](https://github.com/tomatobybike/wukong-gitlog-cli/commit/d853876a5457bac57fbe4a000be90308b49d8b9d))
|
|
12
|
+
|
|
13
|
+
### [1.0.18](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.17...v1.0.18) (2025-12-05)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* 🎸 buildAuthorLatestDataset ([e607836](https://github.com/tomatobybike/wukong-gitlog-cli/commit/e6078360d29e18d9a9f6f6346547821ef13c1e7d))
|
|
19
|
+
* 🎸 css ([f2c4a71](https://github.com/tomatobybike/wukong-gitlog-cli/commit/f2c4a715b594f37e3ff2fd34f641c57aac33c50a))
|
|
20
|
+
* 🎸 risk ([1f6a55e](https://github.com/tomatobybike/wukong-gitlog-cli/commit/1f6a55e68ad28cc9523019294eaa69bde0036d86))
|
|
21
|
+
|
|
5
22
|
### [1.0.17](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.16...v1.0.17) (2025-12-05)
|
|
6
23
|
|
|
7
24
|
|
package/package.json
CHANGED
package/web/app.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
/* eslint-disable import/no-absolute-path */
|
|
2
|
+
/* eslint-disable no-use-before-define */
|
|
3
|
+
/* global echarts */
|
|
2
4
|
const formatDate = (d) => new Date(d).toLocaleString()
|
|
3
5
|
|
|
6
|
+
// ISO 周 key:YYYY-Www
|
|
7
|
+
function getIsoWeekKey(dStr) {
|
|
8
|
+
const d = new Date(dStr)
|
|
9
|
+
if (Number.isNaN(d.valueOf())) return null
|
|
10
|
+
const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
|
|
11
|
+
const dayNum = target.getUTCDay() || 7 // Sunday=0
|
|
12
|
+
target.setUTCDate(target.getUTCDate() + 4 - dayNum)
|
|
13
|
+
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
|
|
14
|
+
const weekNo = Math.ceil(((target - yearStart) / 86400000 + 1) / 7)
|
|
15
|
+
const year = target.getUTCFullYear()
|
|
16
|
+
return `${year}-W${String(weekNo).padStart(2, '0')}`
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
async function loadData() {
|
|
5
20
|
try {
|
|
6
21
|
const [
|
|
@@ -45,8 +60,10 @@ function renderCommitsTablePage() {
|
|
|
45
60
|
const start = (page - 1) * pageSize
|
|
46
61
|
const end = start + pageSize
|
|
47
62
|
filtered.slice(start, end).forEach((c) => {
|
|
63
|
+
// FIXME: remove debug log before production
|
|
64
|
+
console.log('❌', 'c', c);
|
|
48
65
|
const tr = document.createElement('tr')
|
|
49
|
-
tr.innerHTML = `<td>${c.hash.slice(0, 8)}</td><td>${c.author}</td><td>${formatDate(c.date)}</td><td>${c.message}</td>`
|
|
66
|
+
tr.innerHTML = `<td>${c.hash.slice(0, 8)}</td><td>${c.author}</td><td>${formatDate(c.date)}</td><td>${c.message}</td><td>${c.changed}</td>`
|
|
50
67
|
tbody.appendChild(tr)
|
|
51
68
|
})
|
|
52
69
|
}
|
|
@@ -215,13 +232,11 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
215
232
|
if (typeof onHourClick === 'function') {
|
|
216
233
|
chart.on('click', (p) => {
|
|
217
234
|
let hour = Number(p.name)
|
|
218
|
-
if(p.componentType === 'markLine') {
|
|
235
|
+
if (p.componentType === 'markLine') {
|
|
219
236
|
hour = Number(p.data.xAxis)
|
|
220
237
|
}
|
|
221
|
-
// FIXME: remove debug log before production
|
|
222
|
-
console.log('❌', 'hour', hour, p)
|
|
223
238
|
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
224
|
-
if (
|
|
239
|
+
if (Number.isNaN(hour)) return
|
|
225
240
|
onHourClick(hour, commits[hour])
|
|
226
241
|
})
|
|
227
242
|
}
|
|
@@ -1239,12 +1254,12 @@ function computeAndRenderLatestOvertime(latestByDay) {
|
|
|
1239
1254
|
// 每天的 latestHourNormalized → 超出下班的小时数
|
|
1240
1255
|
const dailyOvertime = latestByDay
|
|
1241
1256
|
.map((d) => {
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1257
|
+
let v = null
|
|
1258
|
+
if (typeof d.latestHourNormalized === 'number') {
|
|
1259
|
+
v = d.latestHourNormalized
|
|
1260
|
+
} else if (typeof d.latestHour === 'number') {
|
|
1261
|
+
v = d.latestHour
|
|
1262
|
+
}
|
|
1248
1263
|
if (v == null) return null
|
|
1249
1264
|
const overtime = Math.max(0, Number(v) - endH)
|
|
1250
1265
|
return { date: d.date, overtime, raw: v }
|
|
@@ -1266,21 +1281,6 @@ function computeAndRenderLatestOvertime(latestByDay) {
|
|
|
1266
1281
|
)}</b> 小时,逻辑时间约 ${worstDay.raw.toFixed(2)} 点)`
|
|
1267
1282
|
}
|
|
1268
1283
|
|
|
1269
|
-
// 工具:根据日期字符串计算 ISO 周 key:YYYY-Www
|
|
1270
|
-
const getIsoWeekKey = (dStr) => {
|
|
1271
|
-
const d = new Date(dStr)
|
|
1272
|
-
if (Number.isNaN(d.valueOf())) return null
|
|
1273
|
-
const target = new Date(
|
|
1274
|
-
Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())
|
|
1275
|
-
)
|
|
1276
|
-
const dayNum = target.getUTCDay() || 7 // Sunday=0
|
|
1277
|
-
target.setUTCDate(target.getUTCDate() + 4 - dayNum)
|
|
1278
|
-
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
|
|
1279
|
-
const weekNo = Math.ceil(((target - yearStart) / 86400000 + 1) / 7)
|
|
1280
|
-
const year = target.getUTCFullYear()
|
|
1281
|
-
return `${year}-W${String(weekNo).padStart(2, '0')}`
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
1284
|
// 2) 按周聚合:每周取「该周内任意一天的最大加班时长」
|
|
1285
1285
|
const weekMap = new Map()
|
|
1286
1286
|
dailyOvertime.forEach((d) => {
|
|
@@ -1354,43 +1354,742 @@ function buildDataset(stats, type) {
|
|
|
1354
1354
|
return { authors, allPeriods, series };
|
|
1355
1355
|
}
|
|
1356
1356
|
|
|
1357
|
-
const drawChangeTrends = (stats
|
|
1358
|
-
const el = document.getElementById(
|
|
1359
|
-
if (!el) return null
|
|
1360
|
-
|
|
1361
|
-
console.log('❌', 'el', el);
|
|
1362
|
-
const chart = echarts.init(el);
|
|
1363
|
-
|
|
1364
|
-
function render(type) {
|
|
1365
|
-
const { authors, allPeriods, series } = buildDataset(stats, type);
|
|
1357
|
+
const drawChangeTrends = (stats) => {
|
|
1358
|
+
const el = document.getElementById('chartAuthorChanges')
|
|
1359
|
+
if (!el) return null
|
|
1360
|
+
const chart = echarts.init(el)
|
|
1366
1361
|
|
|
1362
|
+
function render(t) {
|
|
1363
|
+
const { authors, allPeriods, series } = buildDataset(stats, t)
|
|
1367
1364
|
chart.setOption({
|
|
1368
1365
|
tooltip: { trigger: 'axis' },
|
|
1369
1366
|
legend: { data: authors },
|
|
1370
1367
|
xAxis: { type: 'category', data: allPeriods },
|
|
1371
1368
|
yAxis: { type: 'value' },
|
|
1372
1369
|
series
|
|
1373
|
-
})
|
|
1370
|
+
})
|
|
1374
1371
|
}
|
|
1375
1372
|
|
|
1376
1373
|
// 初次渲染:日
|
|
1377
|
-
render('daily')
|
|
1374
|
+
render('daily')
|
|
1378
1375
|
|
|
1379
1376
|
// tabs 切换
|
|
1380
|
-
document.querySelectorAll('#tabs button')
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1377
|
+
const tabs = document.querySelectorAll('#tabs button')
|
|
1378
|
+
tabs.forEach((btnEl) => {
|
|
1379
|
+
btnEl.addEventListener('click', () => {
|
|
1380
|
+
tabs.forEach((b) => b.classList.remove('active'))
|
|
1381
|
+
btnEl.classList.add('active')
|
|
1382
|
+
render(btnEl.dataset.type)
|
|
1383
|
+
})
|
|
1384
|
+
})
|
|
1385
|
+
|
|
1386
|
+
return chart
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// ========= 开发者加班趋势(基于 commits 现场计算) =========
|
|
1390
|
+
function buildAuthorOvertimeDataset(
|
|
1391
|
+
commits,
|
|
1392
|
+
type,
|
|
1393
|
+
startHour,
|
|
1394
|
+
endHour,
|
|
1395
|
+
cutoff
|
|
1396
|
+
) {
|
|
1397
|
+
const byAuthor = new Map()
|
|
1398
|
+
const periods = new Set()
|
|
1399
|
+
|
|
1400
|
+
commits.forEach((c) => {
|
|
1401
|
+
const d = new Date(c.date)
|
|
1402
|
+
if (Number.isNaN(d.valueOf())) return
|
|
1403
|
+
const h = d.getHours()
|
|
1404
|
+
const isOvertime =
|
|
1405
|
+
(h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
|
|
1406
|
+
if (!isOvertime) return
|
|
1407
|
+
|
|
1408
|
+
let key
|
|
1409
|
+
if (type === 'daily') {
|
|
1410
|
+
key = d.toISOString().slice(0, 10)
|
|
1411
|
+
} else if (type === 'weekly') {
|
|
1412
|
+
key = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
1413
|
+
} else {
|
|
1414
|
+
key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
1415
|
+
}
|
|
1416
|
+
if (!key) return
|
|
1417
|
+
periods.add(key)
|
|
1418
|
+
|
|
1419
|
+
const author = c.author || 'unknown'
|
|
1420
|
+
if (!byAuthor.has(author)) byAuthor.set(author, {})
|
|
1421
|
+
const obj = byAuthor.get(author)
|
|
1422
|
+
obj[key] = (obj[key] || 0) + 1
|
|
1423
|
+
})
|
|
1424
|
+
|
|
1425
|
+
const allPeriods = Array.from(periods).sort()
|
|
1426
|
+
const authors = Array.from(byAuthor.keys()).sort()
|
|
1427
|
+
const series = authors.map((a) => ({
|
|
1428
|
+
name: a,
|
|
1429
|
+
type: 'line',
|
|
1430
|
+
smooth: true,
|
|
1431
|
+
data: allPeriods.map((p) => (byAuthor.get(a)[p] || 0))
|
|
1432
|
+
}))
|
|
1433
|
+
return { authors, allPeriods, series }
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function drawAuthorOvertimeTrends(commits, stats) {
|
|
1437
|
+
const el = document.getElementById('chartAuthorOvertime')
|
|
1438
|
+
if (!el) return null
|
|
1439
|
+
const chart = echarts.init(el)
|
|
1440
|
+
|
|
1441
|
+
const startHour =
|
|
1442
|
+
typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
1443
|
+
? stats.startHour
|
|
1444
|
+
: 9
|
|
1445
|
+
const endHour =
|
|
1446
|
+
typeof stats.endHour === 'number' && stats.endHour >= 0
|
|
1447
|
+
? stats.endHour
|
|
1448
|
+
: window.__overtimeEndHour || 18
|
|
1449
|
+
const cutoff = window.__overnightCutoff ?? 6
|
|
1450
|
+
|
|
1451
|
+
function render(type) {
|
|
1452
|
+
const ds = buildAuthorOvertimeDataset(
|
|
1453
|
+
commits,
|
|
1454
|
+
type,
|
|
1455
|
+
startHour,
|
|
1456
|
+
endHour,
|
|
1457
|
+
cutoff
|
|
1458
|
+
)
|
|
1459
|
+
chart.setOption({
|
|
1460
|
+
tooltip: { trigger: 'axis' },
|
|
1461
|
+
legend: { data: ds.authors },
|
|
1462
|
+
xAxis: { type: 'category', data: ds.allPeriods },
|
|
1463
|
+
yAxis: { type: 'value' },
|
|
1464
|
+
series: ds.series
|
|
1465
|
+
})
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// 初始按日
|
|
1469
|
+
render('daily')
|
|
1470
|
+
|
|
1471
|
+
// tabs 切换
|
|
1472
|
+
const tabs = document.querySelectorAll('#tabsOvertime button')
|
|
1473
|
+
tabs.forEach((btnEl) => {
|
|
1474
|
+
btnEl.addEventListener('click', () => {
|
|
1475
|
+
tabs.forEach((b) => b.classList.remove('active'))
|
|
1476
|
+
btnEl.classList.add('active')
|
|
1477
|
+
render(btnEl.dataset.type)
|
|
1478
|
+
})
|
|
1479
|
+
})
|
|
1384
1480
|
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
})
|
|
1481
|
+
// 输出本周风险总结
|
|
1482
|
+
renderWeeklyRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1483
|
+
renderMonthlyRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1484
|
+
renderWeeklyDurationRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1485
|
+
renderMonthlyDurationRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1388
1486
|
|
|
1389
|
-
return chart
|
|
1487
|
+
return chart
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function renderWeeklyRiskSummary(
|
|
1491
|
+
commits,
|
|
1492
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
1493
|
+
) {
|
|
1494
|
+
const box = document.getElementById('weeklyRiskSummary')
|
|
1495
|
+
if (!box) return
|
|
1496
|
+
|
|
1497
|
+
// 获取当前周与上一周 key
|
|
1498
|
+
const now = new Date()
|
|
1499
|
+
const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
|
|
1500
|
+
const prev = new Date(now)
|
|
1501
|
+
prev.setDate(prev.getDate() - 7)
|
|
1502
|
+
const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
|
|
1503
|
+
|
|
1504
|
+
// 统计:每周 -> author -> count;同时统计每周日期集合
|
|
1505
|
+
const weekAuthor = new Map()
|
|
1506
|
+
const weekDatesByAuthor = new Map() // week -> author -> Set(date)
|
|
1507
|
+
|
|
1508
|
+
commits.forEach((c) => {
|
|
1509
|
+
const d = new Date(c.date)
|
|
1510
|
+
if (Number.isNaN(d.valueOf())) return
|
|
1511
|
+
const h = d.getHours()
|
|
1512
|
+
const isOT =
|
|
1513
|
+
(h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
|
|
1514
|
+
if (!isOT) return
|
|
1515
|
+
|
|
1516
|
+
const key = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
1517
|
+
if (!key) return
|
|
1518
|
+
const author = c.author || 'unknown'
|
|
1519
|
+
|
|
1520
|
+
if (!weekAuthor.has(key)) weekAuthor.set(key, new Map())
|
|
1521
|
+
const m = weekAuthor.get(key)
|
|
1522
|
+
m.set(author, (m.get(author) || 0) + 1)
|
|
1523
|
+
|
|
1524
|
+
if (!weekDatesByAuthor.has(key)) weekDatesByAuthor.set(key, new Map())
|
|
1525
|
+
const dMap = weekDatesByAuthor.get(key)
|
|
1526
|
+
if (!dMap.has(author)) dMap.set(author, new Set())
|
|
1527
|
+
dMap.get(author).add(d.toISOString().slice(0, 10))
|
|
1528
|
+
})
|
|
1529
|
+
|
|
1530
|
+
const curMap = weekAuthor.get(curKey) || new Map()
|
|
1531
|
+
const prevMap = weekAuthor.get(prevKey) || new Map()
|
|
1532
|
+
const curTotal = Array.from(curMap.values()).reduce((a, b) => a + b, 0)
|
|
1533
|
+
const prevTotal = Array.from(prevMap.values()).reduce((a, b) => a + b, 0)
|
|
1534
|
+
const delta =
|
|
1535
|
+
prevTotal > 0 ? Math.round(((curTotal - prevTotal) / prevTotal) * 100) : null
|
|
1536
|
+
|
|
1537
|
+
// 找当前周最“活跃”的人(加班提交最多),并统计他加班的自然日数
|
|
1538
|
+
let topAuthor = null
|
|
1539
|
+
let topCount = -1
|
|
1540
|
+
curMap.forEach((v, k) => {
|
|
1541
|
+
if (v > topCount) {
|
|
1542
|
+
topCount = v
|
|
1543
|
+
topAuthor = k
|
|
1544
|
+
}
|
|
1545
|
+
})
|
|
1546
|
+
const curDatesMap = weekDatesByAuthor.get(curKey) || new Map()
|
|
1547
|
+
const topDays =
|
|
1548
|
+
topAuthor && curDatesMap.get(topAuthor)
|
|
1549
|
+
? curDatesMap.get(topAuthor).size
|
|
1550
|
+
: 0
|
|
1551
|
+
|
|
1552
|
+
// 文案
|
|
1553
|
+
const lines = []
|
|
1554
|
+
lines.push('【本周风险总结】')
|
|
1555
|
+
|
|
1556
|
+
if (curTotal === 0) {
|
|
1557
|
+
lines.push('团队本周暂无加班提交。')
|
|
1558
|
+
} else if (delta === null) {
|
|
1559
|
+
lines.push(`团队本周加班提交 ${curTotal} 次。`)
|
|
1560
|
+
} else {
|
|
1561
|
+
const trend = delta >= 0 ? '上升' : '下降'
|
|
1562
|
+
lines.push(`团队加班${trend} ${Math.abs(delta)}%(vs 上周)。`)
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
if (topAuthor && curTotal > 0) {
|
|
1566
|
+
const pct = Math.round((topCount / curTotal) * 100)
|
|
1567
|
+
lines.push(
|
|
1568
|
+
`${topAuthor} 夜间活跃度 ${pct}%,${topDays} 天出现下班后提交(${endHour}:00 后或次日 ${cutoff}:00 前)。`
|
|
1569
|
+
)
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
box.innerHTML = `
|
|
1573
|
+
<div class="risk-summary">
|
|
1574
|
+
<div class="risk-title">【本周风险总结】</div>
|
|
1575
|
+
<ul>
|
|
1576
|
+
${lines
|
|
1577
|
+
.slice(1)
|
|
1578
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
1579
|
+
.join('')}
|
|
1580
|
+
</ul>
|
|
1581
|
+
</div>
|
|
1582
|
+
`
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff) {
|
|
1586
|
+
const byAuthorDay = new Map()
|
|
1587
|
+
commits.forEach((c) => {
|
|
1588
|
+
const d = new Date(c.date)
|
|
1589
|
+
if (Number.isNaN(d.valueOf())) return
|
|
1590
|
+
const h = d.getHours()
|
|
1591
|
+
let overtime = null
|
|
1592
|
+
let dayKey = null
|
|
1593
|
+
if (h >= endHour && h < 24) {
|
|
1594
|
+
overtime = h - endHour
|
|
1595
|
+
dayKey = d.toISOString().slice(0, 10)
|
|
1596
|
+
} else if (h >= 0 && h < cutoff && h < startHour) {
|
|
1597
|
+
overtime = 24 - endHour + h
|
|
1598
|
+
const cur = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
|
|
1599
|
+
cur.setUTCDate(cur.getUTCDate() - 1)
|
|
1600
|
+
dayKey = cur.toISOString().slice(0, 10)
|
|
1601
|
+
}
|
|
1602
|
+
if (overtime == null || !dayKey) return
|
|
1603
|
+
const author = c.author || 'unknown'
|
|
1604
|
+
if (!byAuthorDay.has(author)) byAuthorDay.set(author, new Map())
|
|
1605
|
+
const m = byAuthorDay.get(author)
|
|
1606
|
+
const cur = m.get(dayKey)
|
|
1607
|
+
if (!cur || overtime > cur) m.set(dayKey, overtime)
|
|
1608
|
+
})
|
|
1609
|
+
return byAuthorDay
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function renderWeeklyDurationRiskSummary(
|
|
1613
|
+
commits,
|
|
1614
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
1615
|
+
) {
|
|
1616
|
+
const box = document.getElementById('weeklyDurationRiskSummary')
|
|
1617
|
+
if (!box) return
|
|
1618
|
+
const now = new Date()
|
|
1619
|
+
const curWeek = getIsoWeekKey(now.toISOString().slice(0, 10))
|
|
1620
|
+
const byAuthorDay = computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff)
|
|
1621
|
+
const sums = []
|
|
1622
|
+
byAuthorDay.forEach((dayMap, author) => {
|
|
1623
|
+
let total = 0
|
|
1624
|
+
dayMap.forEach((v, dayKey) => {
|
|
1625
|
+
const wk = getIsoWeekKey(dayKey)
|
|
1626
|
+
if (wk === curWeek) total += v
|
|
1627
|
+
})
|
|
1628
|
+
if (total > 0) sums.push({ author, total })
|
|
1629
|
+
})
|
|
1630
|
+
sums.sort((a, b) => b.total - a.total)
|
|
1631
|
+
const top = sums.slice(0, 6)
|
|
1632
|
+
const lines = []
|
|
1633
|
+
lines.push('【本周加班时长风险】')
|
|
1634
|
+
if (top.length === 0) {
|
|
1635
|
+
lines.push('本周暂无加班时长风险。')
|
|
1636
|
+
} else {
|
|
1637
|
+
top.forEach(({ author, total }) => {
|
|
1638
|
+
let level = '轻度'
|
|
1639
|
+
if (total >= 12) level = '严重'
|
|
1640
|
+
else if (total >= 6) level = '中度'
|
|
1641
|
+
lines.push(`${author} 本周累计加班 ${total.toFixed(2)} 小时(${level})。`)
|
|
1642
|
+
})
|
|
1643
|
+
}
|
|
1644
|
+
box.innerHTML = `
|
|
1645
|
+
<div class="risk-summary">
|
|
1646
|
+
<div class="risk-title">【本周加班时长风险】</div>
|
|
1647
|
+
<ul>
|
|
1648
|
+
${lines
|
|
1649
|
+
.slice(1)
|
|
1650
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
1651
|
+
.join('')}
|
|
1652
|
+
</ul>
|
|
1653
|
+
</div>
|
|
1654
|
+
`
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function renderMonthlyDurationRiskSummary(
|
|
1658
|
+
commits,
|
|
1659
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
1660
|
+
) {
|
|
1661
|
+
const box = document.getElementById('monthlyDurationRiskSummary')
|
|
1662
|
+
if (!box) return
|
|
1663
|
+
const now = new Date()
|
|
1664
|
+
const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
1665
|
+
const byAuthorDay = computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff)
|
|
1666
|
+
const sums = []
|
|
1667
|
+
byAuthorDay.forEach((dayMap, author) => {
|
|
1668
|
+
let total = 0
|
|
1669
|
+
dayMap.forEach((v, dayKey) => {
|
|
1670
|
+
const m = dayKey.slice(0, 7)
|
|
1671
|
+
if (m === curMonth) total += v
|
|
1672
|
+
})
|
|
1673
|
+
if (total > 0) sums.push({ author, total })
|
|
1674
|
+
})
|
|
1675
|
+
sums.sort((a, b) => b.total - a.total)
|
|
1676
|
+
const top = sums.slice(0, 6)
|
|
1677
|
+
const lines = []
|
|
1678
|
+
lines.push('【本月加班时长风险】')
|
|
1679
|
+
if (top.length === 0) {
|
|
1680
|
+
lines.push('本月暂无加班时长风险。')
|
|
1681
|
+
} else {
|
|
1682
|
+
top.forEach(({ author, total }) => {
|
|
1683
|
+
let level = '轻度'
|
|
1684
|
+
if (total >= 20) level = '严重'
|
|
1685
|
+
else if (total >= 10) level = '中度'
|
|
1686
|
+
lines.push(`${author} 本月累计加班 ${total.toFixed(2)} 小时(${level})。`)
|
|
1687
|
+
})
|
|
1688
|
+
}
|
|
1689
|
+
box.innerHTML = `
|
|
1690
|
+
<div class="risk-summary">
|
|
1691
|
+
<div class="risk-title">【本月加班时长风险】</div>
|
|
1692
|
+
<ul>
|
|
1693
|
+
${lines
|
|
1694
|
+
.slice(1)
|
|
1695
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
1696
|
+
.join('')}
|
|
1697
|
+
</ul>
|
|
1698
|
+
</div>
|
|
1699
|
+
`
|
|
1700
|
+
}
|
|
1701
|
+
function renderMonthlyRiskSummary(
|
|
1702
|
+
commits,
|
|
1703
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
1704
|
+
) {
|
|
1705
|
+
const box = document.getElementById('monthlyRiskSummary')
|
|
1706
|
+
if (!box) return
|
|
1707
|
+
|
|
1708
|
+
const now = new Date()
|
|
1709
|
+
const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
1710
|
+
const prev = new Date(now)
|
|
1711
|
+
prev.setMonth(prev.getMonth() - 1)
|
|
1712
|
+
const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
|
|
1713
|
+
|
|
1714
|
+
const monthAuthor = new Map()
|
|
1715
|
+
const monthMax = new Map()
|
|
1716
|
+
|
|
1717
|
+
commits.forEach((c) => {
|
|
1718
|
+
const d = new Date(c.date)
|
|
1719
|
+
if (Number.isNaN(d.valueOf())) return
|
|
1720
|
+
const h = d.getHours()
|
|
1721
|
+
const isOT = (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
|
|
1722
|
+
if (!isOT) return
|
|
1723
|
+
|
|
1724
|
+
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
1725
|
+
const author = c.author || 'unknown'
|
|
1726
|
+
|
|
1727
|
+
if (!monthAuthor.has(key)) monthAuthor.set(key, new Map())
|
|
1728
|
+
const m = monthAuthor.get(key)
|
|
1729
|
+
m.set(author, (m.get(author) || 0) + 1)
|
|
1730
|
+
|
|
1731
|
+
let overtime = null
|
|
1732
|
+
if (h >= endHour && h < 24) overtime = h - endHour
|
|
1733
|
+
else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
|
|
1734
|
+
if (overtime == null) return
|
|
1735
|
+
|
|
1736
|
+
if (!monthMax.has(key)) monthMax.set(key, new Map())
|
|
1737
|
+
const mm = monthMax.get(key)
|
|
1738
|
+
const cur = mm.get(author)
|
|
1739
|
+
const dateStr = d.toISOString().slice(0, 10)
|
|
1740
|
+
if (!cur || overtime > cur.max) mm.set(author, { max: overtime, date: dateStr })
|
|
1741
|
+
})
|
|
1742
|
+
|
|
1743
|
+
const curMap = monthAuthor.get(curKey) || new Map()
|
|
1744
|
+
const prevMap = monthAuthor.get(prevKey) || new Map()
|
|
1745
|
+
const curTotal = Array.from(curMap.values()).reduce((a, b) => a + b, 0)
|
|
1746
|
+
const prevTotal = Array.from(prevMap.values()).reduce((a, b) => a + b, 0)
|
|
1747
|
+
const delta = prevTotal > 0 ? Math.round(((curTotal - prevTotal) / prevTotal) * 100) : null
|
|
1748
|
+
|
|
1749
|
+
let topAuthor = null
|
|
1750
|
+
let top = { max: -1, date: null }
|
|
1751
|
+
const curMaxMap = monthMax.get(curKey) || new Map()
|
|
1752
|
+
curMaxMap.forEach((v, k) => {
|
|
1753
|
+
if (v.max > top.max) {
|
|
1754
|
+
top = v
|
|
1755
|
+
topAuthor = k
|
|
1756
|
+
}
|
|
1757
|
+
})
|
|
1758
|
+
|
|
1759
|
+
let prevMax = -1
|
|
1760
|
+
const prevMaxMap = monthMax.get(prevKey) || new Map()
|
|
1761
|
+
prevMaxMap.forEach((v) => {
|
|
1762
|
+
if (v.max > prevMax) prevMax = v.max
|
|
1763
|
+
})
|
|
1764
|
+
|
|
1765
|
+
const lines = []
|
|
1766
|
+
lines.push('【本月加班风险】')
|
|
1767
|
+
|
|
1768
|
+
if (curTotal === 0) {
|
|
1769
|
+
lines.push('本月尚无下班后提交,未发现明显风险。')
|
|
1770
|
+
} else {
|
|
1771
|
+
if (delta === null) {
|
|
1772
|
+
lines.push(`本月下班后提交 ${curTotal} 次。`)
|
|
1773
|
+
} else {
|
|
1774
|
+
const trend = delta >= 0 ? '上升' : '下降'
|
|
1775
|
+
lines.push(`本月下班后提交${trend} ${Math.abs(delta)}%(vs 上月)。`)
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
if (top.max >= 0) {
|
|
1779
|
+
let trend2 = '暂无上月对比'
|
|
1780
|
+
if (prevMax >= 0) {
|
|
1781
|
+
if (top.max > prevMax) trend2 = '较上月更晚'
|
|
1782
|
+
else if (top.max < prevMax) trend2 = '较上月提前'
|
|
1783
|
+
else trend2 = '与上月持平'
|
|
1784
|
+
}
|
|
1785
|
+
lines.push(`${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend2}。`)
|
|
1786
|
+
if (top.max >= 2) lines.push('已超过 2 小时,存在严重加班风险。')
|
|
1787
|
+
else if (top.max >= 1) lines.push('已超过 1 小时,存在中度加班风险。')
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
box.innerHTML = `
|
|
1792
|
+
<div class="risk-summary">
|
|
1793
|
+
<div class="risk-title">【本月加班风险】</div>
|
|
1794
|
+
<ul>
|
|
1795
|
+
${lines
|
|
1796
|
+
.slice(1)
|
|
1797
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
1798
|
+
.join('')}
|
|
1799
|
+
</ul>
|
|
1800
|
+
</div>
|
|
1801
|
+
`
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// ========= 开发者加班“最晚”趋势(每期取最大超时) =========
|
|
1805
|
+
function buildAuthorLatestDataset(
|
|
1806
|
+
commits,
|
|
1807
|
+
type,
|
|
1808
|
+
startHour,
|
|
1809
|
+
endHour,
|
|
1810
|
+
cutoff
|
|
1811
|
+
) {
|
|
1812
|
+
const byAuthor = new Map()
|
|
1813
|
+
const periods = new Set()
|
|
1814
|
+
|
|
1815
|
+
commits.forEach((c) => {
|
|
1816
|
+
const d = new Date(c.date)
|
|
1817
|
+
if (Number.isNaN(d.valueOf())) return
|
|
1818
|
+
const h = d.getHours()
|
|
1819
|
+
|
|
1820
|
+
let overtime = null
|
|
1821
|
+
if (h >= endHour && h < 24) overtime = h - endHour
|
|
1822
|
+
else if (h >= 0 && h < cutoff && h < startHour)
|
|
1823
|
+
overtime = 24 - endHour + h
|
|
1824
|
+
if (overtime == null) return
|
|
1825
|
+
|
|
1826
|
+
let key
|
|
1827
|
+
if (type === 'daily') {
|
|
1828
|
+
key = d.toISOString().slice(0, 10)
|
|
1829
|
+
} else if (type === 'weekly') {
|
|
1830
|
+
key = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
1831
|
+
} else {
|
|
1832
|
+
key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
1833
|
+
}
|
|
1834
|
+
if (!key) return
|
|
1835
|
+
periods.add(key)
|
|
1836
|
+
|
|
1837
|
+
const author = c.author || 'unknown'
|
|
1838
|
+
if (!byAuthor.has(author)) byAuthor.set(author, {})
|
|
1839
|
+
const obj = byAuthor.get(author)
|
|
1840
|
+
obj[key] = Math.max(obj[key] || 0, overtime)
|
|
1841
|
+
})
|
|
1842
|
+
|
|
1843
|
+
const allPeriods = Array.from(periods).sort()
|
|
1844
|
+
const authors = Array.from(byAuthor.keys()).sort()
|
|
1845
|
+
const series = authors.map((a) => ({
|
|
1846
|
+
name: a,
|
|
1847
|
+
type: 'line',
|
|
1848
|
+
smooth: true,
|
|
1849
|
+
data: allPeriods.map((p) => byAuthor.get(a)[p] || 0)
|
|
1850
|
+
}))
|
|
1851
|
+
return { authors, allPeriods, series }
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function drawAuthorLatestOvertimeTrends(commits, stats) {
|
|
1855
|
+
const el = document.getElementById('chartAuthorLatestOvertime')
|
|
1856
|
+
if (!el) return null
|
|
1857
|
+
const chart = echarts.init(el)
|
|
1858
|
+
|
|
1859
|
+
const startHour =
|
|
1860
|
+
typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
1861
|
+
? stats.startHour
|
|
1862
|
+
: 9
|
|
1863
|
+
const endHour =
|
|
1864
|
+
typeof stats.endHour === 'number' && stats.endHour >= 0
|
|
1865
|
+
? stats.endHour
|
|
1866
|
+
: window.__overtimeEndHour || 18
|
|
1867
|
+
const cutoff = window.__overnightCutoff ?? 6
|
|
1868
|
+
|
|
1869
|
+
function render(type) {
|
|
1870
|
+
const ds = buildAuthorLatestDataset(
|
|
1871
|
+
commits,
|
|
1872
|
+
type,
|
|
1873
|
+
startHour,
|
|
1874
|
+
endHour,
|
|
1875
|
+
cutoff
|
|
1876
|
+
)
|
|
1877
|
+
chart.setOption({
|
|
1878
|
+
tooltip: { trigger: 'axis' },
|
|
1879
|
+
legend: { data: ds.authors },
|
|
1880
|
+
xAxis: { type: 'category', data: ds.allPeriods },
|
|
1881
|
+
yAxis: {
|
|
1882
|
+
type: 'value',
|
|
1883
|
+
name: '超出下班(小时)',
|
|
1884
|
+
min: 0
|
|
1885
|
+
},
|
|
1886
|
+
series: ds.series
|
|
1887
|
+
})
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
render('daily')
|
|
1891
|
+
|
|
1892
|
+
const tabs = document.querySelectorAll('#tabsLatestOvertime button')
|
|
1893
|
+
tabs.forEach((btnEl) => {
|
|
1894
|
+
btnEl.addEventListener('click', () => {
|
|
1895
|
+
tabs.forEach((b) => b.classList.remove('active'))
|
|
1896
|
+
btnEl.classList.add('active')
|
|
1897
|
+
render(btnEl.dataset.type)
|
|
1898
|
+
})
|
|
1899
|
+
})
|
|
1900
|
+
|
|
1901
|
+
renderLatestRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1902
|
+
renderLatestMonthlyRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1903
|
+
|
|
1904
|
+
return chart
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// 本周“最晚加班”风险提示
|
|
1908
|
+
function renderLatestRiskSummary(
|
|
1909
|
+
commits,
|
|
1910
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
1911
|
+
) {
|
|
1912
|
+
const box = document.getElementById('latestRiskSummary')
|
|
1913
|
+
if (!box) return
|
|
1914
|
+
|
|
1915
|
+
const now = new Date()
|
|
1916
|
+
const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
|
|
1917
|
+
const prev = new Date(now)
|
|
1918
|
+
prev.setDate(prev.getDate() - 7)
|
|
1919
|
+
const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
|
|
1920
|
+
|
|
1921
|
+
// 统计每周每人最大超时
|
|
1922
|
+
const weekMax = new Map() // week -> Map(author -> {max, date})
|
|
1923
|
+
commits.forEach((c) => {
|
|
1924
|
+
const d = new Date(c.date)
|
|
1925
|
+
if (Number.isNaN(d.valueOf())) return
|
|
1926
|
+
const h = d.getHours()
|
|
1927
|
+
let overtime = null
|
|
1928
|
+
if (h >= endHour && h < 24) overtime = h - endHour
|
|
1929
|
+
else if (h >= 0 && h < cutoff && h < startHour)
|
|
1930
|
+
overtime = 24 - endHour + h
|
|
1931
|
+
if (overtime == null) return
|
|
1932
|
+
|
|
1933
|
+
const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
1934
|
+
if (!wKey) return
|
|
1935
|
+
if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
|
|
1936
|
+
const m = weekMax.get(wKey)
|
|
1937
|
+
const author = c.author || 'unknown'
|
|
1938
|
+
const cur = m.get(author)
|
|
1939
|
+
if (!cur || overtime > cur.max) {
|
|
1940
|
+
m.set(author, { max: overtime, date: d.toISOString().slice(0, 10) })
|
|
1941
|
+
}
|
|
1942
|
+
})
|
|
1943
|
+
|
|
1944
|
+
const curMap = weekMax.get(curKey) || new Map()
|
|
1945
|
+
const prevMap = weekMax.get(prevKey) || new Map()
|
|
1946
|
+
|
|
1947
|
+
// 当前周的全局最晚
|
|
1948
|
+
let topAuthor = null
|
|
1949
|
+
let top = { max: -1, date: null }
|
|
1950
|
+
curMap.forEach((v, k) => {
|
|
1951
|
+
if (v.max > top.max) {
|
|
1952
|
+
top = v
|
|
1953
|
+
topAuthor = k
|
|
1954
|
+
}
|
|
1955
|
+
})
|
|
1956
|
+
|
|
1957
|
+
// 上周全局最晚,用于趋势判断
|
|
1958
|
+
let prevMax = -1
|
|
1959
|
+
prevMap.forEach((v) => {
|
|
1960
|
+
if (v.max > prevMax) prevMax = v.max
|
|
1961
|
+
})
|
|
1962
|
+
|
|
1963
|
+
const lines = []
|
|
1964
|
+
lines.push('【本周最晚加班风险】')
|
|
1965
|
+
|
|
1966
|
+
if (top.max < 0) {
|
|
1967
|
+
lines.push('本周尚无下班后/凌晨提交,未发现明显风险。')
|
|
1968
|
+
} else {
|
|
1969
|
+
let trend = '暂无上周对比'
|
|
1970
|
+
if (prevMax >= 0) {
|
|
1971
|
+
if (top.max > prevMax) trend = '较上周更晚'
|
|
1972
|
+
else if (top.max < prevMax) trend = '较上周提前'
|
|
1973
|
+
else trend = '与上周持平'
|
|
1974
|
+
}
|
|
1975
|
+
lines.push(
|
|
1976
|
+
`${topAuthor} 本周最晚超出下班 ${top.max.toFixed(
|
|
1977
|
+
2
|
|
1978
|
+
)} 小时(${top.date}),${trend}。`
|
|
1979
|
+
)
|
|
1980
|
+
if (top.max >= 2) {
|
|
1981
|
+
lines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
|
|
1982
|
+
} else if (top.max >= 1) {
|
|
1983
|
+
lines.push('已超过 1 小时,注意控制夜间工作时长。')
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
box.innerHTML = `
|
|
1988
|
+
<div class="risk-summary">
|
|
1989
|
+
<div class="risk-title">【本周最晚加班风险】</div>
|
|
1990
|
+
<ul>
|
|
1991
|
+
${lines
|
|
1992
|
+
.slice(1)
|
|
1993
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
1994
|
+
.join('')}
|
|
1995
|
+
</ul>
|
|
1996
|
+
</div>
|
|
1997
|
+
`
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function renderLatestMonthlyRiskSummary(
|
|
2001
|
+
commits,
|
|
2002
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
2003
|
+
) {
|
|
2004
|
+
const box = document.getElementById('latestMonthlyRiskSummary')
|
|
2005
|
+
if (!box) return
|
|
2006
|
+
|
|
2007
|
+
const now = new Date()
|
|
2008
|
+
const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
2009
|
+
const prev = new Date(now)
|
|
2010
|
+
prev.setMonth(prev.getMonth() - 1)
|
|
2011
|
+
const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
|
|
2012
|
+
|
|
2013
|
+
const monthMax = new Map()
|
|
2014
|
+
commits.forEach((c) => {
|
|
2015
|
+
const d = new Date(c.date)
|
|
2016
|
+
if (Number.isNaN(d.valueOf())) return
|
|
2017
|
+
const h = d.getHours()
|
|
2018
|
+
let overtime = null
|
|
2019
|
+
if (h >= endHour && h < 24) overtime = h - endHour
|
|
2020
|
+
else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
|
|
2021
|
+
if (overtime == null) return
|
|
2022
|
+
|
|
2023
|
+
const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
2024
|
+
if (!monthMax.has(mKey)) monthMax.set(mKey, new Map())
|
|
2025
|
+
const m = monthMax.get(mKey)
|
|
2026
|
+
const author = c.author || 'unknown'
|
|
2027
|
+
const cur = m.get(author)
|
|
2028
|
+
if (!cur || overtime > cur.max) {
|
|
2029
|
+
m.set(author, { max: overtime, date: d.toISOString().slice(0, 10) })
|
|
2030
|
+
}
|
|
2031
|
+
})
|
|
2032
|
+
|
|
2033
|
+
const curMap = monthMax.get(curKey) || new Map()
|
|
2034
|
+
const prevMap = monthMax.get(prevKey) || new Map()
|
|
2035
|
+
|
|
2036
|
+
let topAuthor = null
|
|
2037
|
+
let top = { max: -1, date: null }
|
|
2038
|
+
curMap.forEach((v, k) => {
|
|
2039
|
+
if (v.max > top.max) {
|
|
2040
|
+
top = v
|
|
2041
|
+
topAuthor = k
|
|
2042
|
+
}
|
|
2043
|
+
})
|
|
2044
|
+
|
|
2045
|
+
let prevMax = -1
|
|
2046
|
+
prevMap.forEach((v) => {
|
|
2047
|
+
if (v.max > prevMax) prevMax = v.max
|
|
2048
|
+
})
|
|
2049
|
+
|
|
2050
|
+
const lines = []
|
|
2051
|
+
lines.push('【本月最晚加班风险】')
|
|
2052
|
+
|
|
2053
|
+
if (top.max < 0) {
|
|
2054
|
+
lines.push('本月尚无下班后/凌晨提交,未发现明显风险。')
|
|
2055
|
+
} else {
|
|
2056
|
+
let trend = '暂无上月对比'
|
|
2057
|
+
if (prevMax >= 0) {
|
|
2058
|
+
if (top.max > prevMax) trend = '较上月更晚'
|
|
2059
|
+
else if (top.max < prevMax) trend = '较上月提前'
|
|
2060
|
+
else trend = '与上月持平'
|
|
2061
|
+
}
|
|
2062
|
+
lines.push(`${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend}。`)
|
|
2063
|
+
if (top.max >= 2) {
|
|
2064
|
+
lines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
|
|
2065
|
+
} else if (top.max >= 1) {
|
|
2066
|
+
lines.push('已超过 1 小时,注意控制夜间工作时长。')
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
box.innerHTML = `
|
|
2071
|
+
<div class="risk-summary">
|
|
2072
|
+
<div class="risk-title">【本月最晚加班风险】</div>
|
|
2073
|
+
<ul>
|
|
2074
|
+
${lines
|
|
2075
|
+
.slice(1)
|
|
2076
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2077
|
+
.join('')}
|
|
2078
|
+
</ul>
|
|
2079
|
+
</div>
|
|
2080
|
+
`
|
|
1390
2081
|
}
|
|
1391
2082
|
|
|
1392
2083
|
async function main() {
|
|
1393
|
-
const {
|
|
2084
|
+
const {
|
|
2085
|
+
commits,
|
|
2086
|
+
stats,
|
|
2087
|
+
weekly,
|
|
2088
|
+
monthly,
|
|
2089
|
+
latestByDay,
|
|
2090
|
+
config,
|
|
2091
|
+
authorChanges
|
|
2092
|
+
} =
|
|
1394
2093
|
await loadData()
|
|
1395
2094
|
commitsAll = commits
|
|
1396
2095
|
filtered = commitsAll.slice()
|
|
@@ -1404,7 +2103,7 @@ async function main() {
|
|
|
1404
2103
|
updatePager()
|
|
1405
2104
|
renderCommitsTablePage()
|
|
1406
2105
|
|
|
1407
|
-
drawHourlyOvertime(stats, (hour
|
|
2106
|
+
drawHourlyOvertime(stats, (hour) => {
|
|
1408
2107
|
// 使用举例
|
|
1409
2108
|
const hourCommitsDetail = groupCommitsByHour(commits)
|
|
1410
2109
|
// 将 commit 列表传给侧栏(若没有详情,则传空数组)
|
|
@@ -1431,7 +2130,9 @@ async function main() {
|
|
|
1431
2130
|
|
|
1432
2131
|
console.log('最累的一天:', daily.analysis.mostTiredDay)
|
|
1433
2132
|
|
|
1434
|
-
drawChangeTrends(authorChanges
|
|
2133
|
+
drawChangeTrends(authorChanges)
|
|
2134
|
+
drawAuthorOvertimeTrends(commits, stats)
|
|
2135
|
+
drawAuthorLatestOvertimeTrends(commits, stats)
|
|
1435
2136
|
computeAndRenderLatestOvertime(latestByDay)
|
|
1436
2137
|
renderKpi(stats)
|
|
1437
2138
|
}
|
package/web/index.html
CHANGED
|
@@ -53,18 +53,20 @@
|
|
|
53
53
|
<h2>按日提交趋势</h2>
|
|
54
54
|
<div id="dailyTrendChartDog" class="echart"></div>
|
|
55
55
|
</div>
|
|
56
|
-
<div
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
<div class="chart-card">
|
|
57
|
+
<div id="mostTiredDay"></div>
|
|
58
|
+
<div id="mostTiredWeek"></div>
|
|
59
|
+
<div id="mostTiredMonth"></div>
|
|
60
|
+
<div id="latestOvertimeDay"></div>
|
|
61
|
+
<div id="latestOvertimeWeek"></div>
|
|
62
|
+
<div id="latestOvertimeMonth"></div>
|
|
63
|
+
</div>
|
|
62
64
|
</section>
|
|
63
65
|
|
|
64
66
|
<div class="chart-card">
|
|
65
67
|
<h2>开发者 Changed 工作量趋势</h2>
|
|
66
68
|
|
|
67
|
-
<div id="tabs">
|
|
69
|
+
<div id="tabs" class="tabs">
|
|
68
70
|
<button data-type="daily" class="active">按日</button>
|
|
69
71
|
<button data-type="weekly">按周</button>
|
|
70
72
|
<button data-type="monthly">按月</button>
|
|
@@ -72,6 +74,32 @@
|
|
|
72
74
|
<div id="chartAuthorChanges" class="echart"></div>
|
|
73
75
|
</div>
|
|
74
76
|
|
|
77
|
+
<div class="chart-card">
|
|
78
|
+
<h2>开发者 加班趋势</h2>
|
|
79
|
+
<div class="tabs" id="tabsOvertime">
|
|
80
|
+
<button data-type="daily" class="active">按日</button>
|
|
81
|
+
<button data-type="weekly">按周</button>
|
|
82
|
+
<button data-type="monthly">按月</button>
|
|
83
|
+
</div>
|
|
84
|
+
<div id="chartAuthorOvertime" class="echart"></div>
|
|
85
|
+
<div id="weeklyRiskSummary" class="risk-summary-box"></div>
|
|
86
|
+
<div id="monthlyRiskSummary" class="risk-summary-box"></div>
|
|
87
|
+
<div id="weeklyDurationRiskSummary" class="risk-summary-box"></div>
|
|
88
|
+
<div id="monthlyDurationRiskSummary" class="risk-summary-box"></div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div class="chart-card">
|
|
92
|
+
<h2>开发者 加班最晚趋势</h2>
|
|
93
|
+
<div class="tabs" id="tabsLatestOvertime">
|
|
94
|
+
<button data-type="daily" class="active">按日</button>
|
|
95
|
+
<button data-type="weekly">按周</button>
|
|
96
|
+
<button data-type="monthly">按月</button>
|
|
97
|
+
</div>
|
|
98
|
+
<div id="chartAuthorLatestOvertime" class="echart"></div>
|
|
99
|
+
<div id="latestRiskSummary" class="risk-summary-box"></div>
|
|
100
|
+
<div id="latestMonthlyRiskSummary" class="risk-summary-box"></div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
75
103
|
<section class="table-card">
|
|
76
104
|
<h2>提交清单</h2>
|
|
77
105
|
<div id="tableControls">
|
|
@@ -100,6 +128,7 @@
|
|
|
100
128
|
<th>Author</th>
|
|
101
129
|
<th>Date</th>
|
|
102
130
|
<th>Message</th>
|
|
131
|
+
<th>Changed</th>
|
|
103
132
|
</tr>
|
|
104
133
|
</thead>
|
|
105
134
|
<tbody></tbody>
|
package/web/static/style.css
CHANGED
|
@@ -346,6 +346,82 @@ td {
|
|
|
346
346
|
word-break: break-word;
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
/* Tabs(工作量/加班趋势) ----------------------------------- */
|
|
350
|
+
.tabs {
|
|
351
|
+
display: inline-flex;
|
|
352
|
+
gap: 8px;
|
|
353
|
+
margin-bottom: 10px;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.tabs button {
|
|
357
|
+
padding: 8px 14px;
|
|
358
|
+
border-radius: 999px;
|
|
359
|
+
border: 1px solid rgba(148, 163, 184, 0.5);
|
|
360
|
+
background: #fff;
|
|
361
|
+
color: #111827;
|
|
362
|
+
cursor: pointer;
|
|
363
|
+
font-size: 13px;
|
|
364
|
+
transition:
|
|
365
|
+
background 0.2s ease,
|
|
366
|
+
color 0.2s ease,
|
|
367
|
+
border-color 0.2s ease,
|
|
368
|
+
box-shadow 0.2s ease;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.tabs button:hover {
|
|
372
|
+
background: #f3f4ff;
|
|
373
|
+
border-color: #6366f1;
|
|
374
|
+
color: #111827;
|
|
375
|
+
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.16);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.tabs button.active {
|
|
379
|
+
background: #4f46e5;
|
|
380
|
+
color: #fff;
|
|
381
|
+
border-color: #4f46e5;
|
|
382
|
+
box-shadow: 0 3px 10px rgba(79, 70, 229, 0.25);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* 风险总结卡片 ---------------------------------------------- */
|
|
386
|
+
/* 风险总结卡片 — MUI风格 ---------------------------------------------- */
|
|
387
|
+
.risk-summary {
|
|
388
|
+
margin-top: 16px;
|
|
389
|
+
padding: 16px 18px;
|
|
390
|
+
border-radius: 12px;
|
|
391
|
+
|
|
392
|
+
/* 采用 MUI Error Alert 的色板 */
|
|
393
|
+
background: rgba(253, 237, 237, 0.8); /* MUI error.light */
|
|
394
|
+
border: 1px solid rgba(244, 199, 199, 0.9);
|
|
395
|
+
|
|
396
|
+
/* MUI 风格的柔和阴影 */
|
|
397
|
+
box-shadow: 0px 3px 6px rgba(0, 0, 0, 0.08),
|
|
398
|
+
0px 1px 2px rgba(0, 0, 0, 0.04);
|
|
399
|
+
|
|
400
|
+
color: #b71c1c; /* 比 #991b1b 更接近 MUI error.dark */
|
|
401
|
+
font-size: 14px;
|
|
402
|
+
line-height: 1.65;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/* 标题更粗+颜色更深 */
|
|
406
|
+
.risk-summary .risk-title {
|
|
407
|
+
font-weight: 700;
|
|
408
|
+
font-size: 15px;
|
|
409
|
+
margin-bottom: 6px;
|
|
410
|
+
color: #b71c1c;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* 列表 */
|
|
414
|
+
.risk-summary ul {
|
|
415
|
+
margin: 8px 0 0 20px;
|
|
416
|
+
padding: 0;
|
|
417
|
+
color: #7f1d1d;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.risk-summary li {
|
|
421
|
+
margin-bottom: 6px;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
349
425
|
/* 底部 -------------------------------------------------- */
|
|
350
426
|
footer {
|
|
351
427
|
text-align: center;
|