wukong-gitlog-cli 1.0.14 → 1.0.15
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/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/package.json +1 -1
- package/src/cli.mjs +107 -19
- package/web/app.js +340 -68
- package/web/index.html +4 -12
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.15](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.14...v1.0.15) (2025-12-03)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* 🎸 dayDetailSidebar ([21b99fd](https://github.com/tomatobybike/wukong-gitlog-cli/commit/21b99fd859af45641987dfd5f50a8c5604a380c5))
|
|
11
|
+
* 🎸 each day ([ffa3d5e](https://github.com/tomatobybike/wukong-gitlog-cli/commit/ffa3d5e6192a69a727c747ffa325b56ae5bf78d4))
|
|
12
|
+
* 🎸 hourlyOvertimeChart ([93f62b7](https://github.com/tomatobybike/wukong-gitlog-cli/commit/93f62b7b9906154a16bdcf38bd7b0979d44bebfe))
|
|
13
|
+
* 🎸 latestOvertimeDay ([40cb157](https://github.com/tomatobybike/wukong-gitlog-cli/commit/40cb15788411441306e0c838108817fa36268624))
|
|
14
|
+
* 🎸 next day cutof ([efe6a5f](https://github.com/tomatobybike/wukong-gitlog-cli/commit/efe6a5f41e54ca0fdadb3c0071f1fcf7a286e6c2))
|
|
15
|
+
* 🎸 onDayClick ([9132b54](https://github.com/tomatobybike/wukong-gitlog-cli/commit/9132b5423449ceb8a964d813dfd4931834993913))
|
|
16
|
+
* 🎸 renderKpi ([e26cc0d](https://github.com/tomatobybike/wukong-gitlog-cli/commit/e26cc0deac83a7e9bccaae81ba572da0e39e82b3))
|
|
17
|
+
* 🎸 week range ([cd565da](https://github.com/tomatobybike/wukong-gitlog-cli/commit/cd565dac98b6c38442eeacdd6dd423b93d657ab0))
|
|
18
|
+
|
|
5
19
|
### [1.0.14](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.13...v1.0.14) (2025-12-02)
|
|
6
20
|
|
|
7
21
|
|
package/README.md
CHANGED
package/README.zh-CN.md
CHANGED
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk'
|
|
2
2
|
import { Command } from 'commander'
|
|
3
3
|
import dayjs from 'dayjs'
|
|
4
|
+
import isoWeek from 'dayjs/plugin/isoWeek.js'
|
|
4
5
|
import fs from 'fs'
|
|
5
6
|
import path from 'path'
|
|
6
7
|
import { fileURLToPath } from 'url'
|
|
@@ -31,6 +32,8 @@ const pkg = JSON.parse(
|
|
|
31
32
|
fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')
|
|
32
33
|
)
|
|
33
34
|
|
|
35
|
+
dayjs.extend(isoWeek)
|
|
36
|
+
|
|
34
37
|
const PKG_NAME = pkg.name
|
|
35
38
|
const VERSION = pkg.version
|
|
36
39
|
|
|
@@ -51,6 +54,21 @@ const version = async () => {
|
|
|
51
54
|
process.exit(0)
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
/** 将 "2025-W48" → { start: '2025-11-24', end: '2025-11-30' } */
|
|
58
|
+
export function getWeekRange(periodStr) {
|
|
59
|
+
// periodStr = "2025-W48"
|
|
60
|
+
const [year, w] = periodStr.split('-W')
|
|
61
|
+
const week = parseInt(w, 10)
|
|
62
|
+
|
|
63
|
+
const start = dayjs().year(year).isoWeek(week).startOf('week') // Monday
|
|
64
|
+
const end = dayjs().year(year).isoWeek(week).endOf('week') // Sunday
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
start: start.format('YYYY-MM-DD'),
|
|
68
|
+
end: end.format('YYYY-MM-DD')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
const main = async () => {
|
|
55
73
|
const program = new Command()
|
|
56
74
|
|
|
@@ -343,6 +361,7 @@ const main = async () => {
|
|
|
343
361
|
})
|
|
344
362
|
return {
|
|
345
363
|
period: k,
|
|
364
|
+
range: getWeekRange(k),
|
|
346
365
|
total: s.total,
|
|
347
366
|
outsideWorkCount: s.outsideWorkCount,
|
|
348
367
|
outsideWorkRate: s.outsideWorkRate,
|
|
@@ -380,7 +399,10 @@ const main = async () => {
|
|
|
380
399
|
nonWorkdayRate: s.nonWorkdayRate
|
|
381
400
|
}
|
|
382
401
|
})
|
|
383
|
-
const dataMonthlyFile = outputFilePath(
|
|
402
|
+
const dataMonthlyFile = outputFilePath(
|
|
403
|
+
'data/overtime-monthly.mjs',
|
|
404
|
+
outDir
|
|
405
|
+
)
|
|
384
406
|
const monthlyModule = `export default ${JSON.stringify(monthlySeries, null, 2)};\n`
|
|
385
407
|
writeTextFile(dataMonthlyFile, monthlyModule)
|
|
386
408
|
console.log(chalk.green(`Monthly series 已导出: ${dataMonthlyFile}`))
|
|
@@ -388,29 +410,92 @@ const main = async () => {
|
|
|
388
410
|
// 新增:每日最晚提交小时(用于显著展示加班严重程度)
|
|
389
411
|
const dayGroups2 = groupRecords(records, 'day')
|
|
390
412
|
const dayKeys2 = Object.keys(dayGroups2).sort()
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
413
|
+
|
|
414
|
+
// 次日凌晨归并窗口(默认 6 点前仍算前一天的加班)
|
|
415
|
+
const overnightCutoff = Number.isFinite(opts.overnightCutoff)
|
|
416
|
+
? opts.overnightCutoff
|
|
417
|
+
: 6
|
|
418
|
+
// 次日上班时间(默认按 workStart,若未指定则 9 点)
|
|
419
|
+
const workStartHour =
|
|
420
|
+
opts.workStart || opts.workStart === 0 ? opts.workStart : 9
|
|
421
|
+
const workEndHour =
|
|
422
|
+
opts.workEnd || opts.workEnd === 0 ? opts.workEnd : 18
|
|
423
|
+
|
|
424
|
+
// 有些日期「本身没有 commit」,但第二天凌晨有提交要归并到这一天,
|
|
425
|
+
// 需要补出这些“虚拟日期”,否则 latestByDay 会漏掉这天。
|
|
426
|
+
const virtualPrevDays = new Set()
|
|
427
|
+
records.forEach((r) => {
|
|
428
|
+
const d = new Date(r.date)
|
|
429
|
+
if (Number.isNaN(d.valueOf())) return
|
|
430
|
+
const h = d.getHours()
|
|
431
|
+
if (h < 0 || h >= overnightCutoff || h >= workStartHour) return
|
|
432
|
+
const curDay = dayjs(d).format('YYYY-MM-DD')
|
|
433
|
+
const prevDay = dayjs(curDay).subtract(1, 'day').format('YYYY-MM-DD')
|
|
434
|
+
if (!dayGroups2[prevDay]) {
|
|
435
|
+
virtualPrevDays.add(prevDay)
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
const allDayKeys = Array.from(
|
|
440
|
+
new Set([...dayKeys2, ...virtualPrevDays])
|
|
441
|
+
).sort()
|
|
442
|
+
|
|
443
|
+
const latestByDay = allDayKeys.map((k) => {
|
|
444
|
+
const list = dayGroups2[k] || []
|
|
445
|
+
|
|
446
|
+
// 1) 当天「下班后」的提交:只统计 >= workEndHour 的小时
|
|
447
|
+
const sameDayHours = list
|
|
448
|
+
.map((r) => new Date(r.date))
|
|
449
|
+
.filter((d) => !Number.isNaN(d.valueOf()))
|
|
450
|
+
.map((d) => d.getHours())
|
|
451
|
+
.filter((h) => h >= workEndHour && h < 24)
|
|
452
|
+
|
|
453
|
+
// 2) 次日凌晨、但仍算前一日加班的提交
|
|
400
454
|
const nextKey = dayjs(k).add(1, 'day').format('YYYY-MM-DD')
|
|
401
455
|
const early = dayGroups2[nextKey] || []
|
|
402
456
|
const earlyHours = early
|
|
403
457
|
.map((r) => new Date(r.date))
|
|
404
458
|
.filter((d) => !Number.isNaN(d.valueOf()))
|
|
405
459
|
.map((d) => d.getHours())
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
460
|
+
// 只看 [0, overnightCutoff) 之间的小时,
|
|
461
|
+
// 并且默认认为 < workStartHour 属于「次日上班前」
|
|
462
|
+
.filter(
|
|
463
|
+
(h) =>
|
|
464
|
+
h >= 0 &&
|
|
465
|
+
h < overnightCutoff &&
|
|
466
|
+
// 保护性判断:若有人把 overnightCutoff 设得大于上班时间,
|
|
467
|
+
// 我们仍然只统计到上班时间为止
|
|
468
|
+
h < workStartHour
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
// 3) 计算「逻辑上的最晚加班时间」
|
|
472
|
+
// - 当天晚上的用原始小时(如 22 点)
|
|
473
|
+
// - 次日凌晨的用 24 + 小时(如 1 点 → 25)
|
|
474
|
+
const overtimeValues = [
|
|
475
|
+
...sameDayHours.map((h) => h),
|
|
476
|
+
...earlyHours.map((h) => 24 + h)
|
|
477
|
+
]
|
|
478
|
+
|
|
479
|
+
if (overtimeValues.length === 0) {
|
|
480
|
+
// 这一天没有任何「下班后到次日上班前」的提交
|
|
481
|
+
return {
|
|
482
|
+
date: k,
|
|
483
|
+
latestHour: null,
|
|
484
|
+
latestHourNormalized: null
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const latestHourNormalized = Math.max(...overtimeValues)
|
|
489
|
+
|
|
490
|
+
// latestHour 保留「当天自然日内」的最晚提交通常小时数,供前端需要时参考
|
|
491
|
+
const sameDayMax =
|
|
492
|
+
sameDayHours.length > 0 ? Math.max(...sameDayHours) : null
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
date: k,
|
|
496
|
+
latestHour: sameDayMax,
|
|
497
|
+
latestHourNormalized
|
|
498
|
+
}
|
|
414
499
|
})
|
|
415
500
|
const dataLatestByDayFile = outputFilePath(
|
|
416
501
|
'data/overtime-latest-by-day.mjs',
|
|
@@ -432,7 +517,10 @@ const main = async () => {
|
|
|
432
517
|
lunchEnd: opts.lunchEnd || 14,
|
|
433
518
|
overnightCutoff
|
|
434
519
|
}
|
|
435
|
-
writeTextFile(
|
|
520
|
+
writeTextFile(
|
|
521
|
+
configFile,
|
|
522
|
+
`export default ${JSON.stringify(cfg, null, 2)};\n`
|
|
523
|
+
)
|
|
436
524
|
console.log(chalk.green(`Config 已导出: ${configFile}`))
|
|
437
525
|
} catch (e) {
|
|
438
526
|
console.warn('Export config failed:', e && e.message ? e.message : e)
|
package/web/app.js
CHANGED
|
@@ -129,7 +129,7 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
129
129
|
trigger: 'axis',
|
|
130
130
|
formatter(params) {
|
|
131
131
|
const p = params[0]
|
|
132
|
-
const h = parseInt(p.axisValue,10)
|
|
132
|
+
const h = parseInt(p.axisValue, 10)
|
|
133
133
|
const count = p.value
|
|
134
134
|
const rate = (percent[h] * 100).toFixed(1)
|
|
135
135
|
return `
|
|
@@ -212,9 +212,10 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
212
212
|
// showSideBarForHour 实现
|
|
213
213
|
function showSideBarForHour(hour, commitsOrCount) {
|
|
214
214
|
// 支持传入 number(仅次数)或 array(详细 commit 列表)
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
const
|
|
215
|
+
// 统一复用通用详情侧栏 DOM
|
|
216
|
+
const sidebar = document.getElementById('dayDetailSidebar')
|
|
217
|
+
const titleEl = document.getElementById('sidebarTitle')
|
|
218
|
+
const contentEl = document.getElementById('sidebarContent')
|
|
218
219
|
|
|
219
220
|
// 兼容未传入侧栏 DOM 的情况(优雅降级)
|
|
220
221
|
if (!sidebar || !titleEl || !contentEl) {
|
|
@@ -260,14 +261,6 @@ function showSideBarForHour(hour, commitsOrCount) {
|
|
|
260
261
|
sidebar.classList.add('show')
|
|
261
262
|
}
|
|
262
263
|
|
|
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
264
|
// 简单的 HTML 转义,防止 XSS 与布局断裂
|
|
272
265
|
function escapeHtml(str = '') {
|
|
273
266
|
return String(str)
|
|
@@ -301,7 +294,7 @@ function drawOutsideVsInside(stats) {
|
|
|
301
294
|
return chart
|
|
302
295
|
}
|
|
303
296
|
|
|
304
|
-
function drawDailyTrend(commits) {
|
|
297
|
+
function drawDailyTrend(commits, onDayClick) {
|
|
305
298
|
if (!Array.isArray(commits) || commits.length === 0) return null
|
|
306
299
|
|
|
307
300
|
// 聚合每日提交数量
|
|
@@ -398,10 +391,60 @@ function drawDailyTrend(commits) {
|
|
|
398
391
|
]
|
|
399
392
|
})
|
|
400
393
|
|
|
394
|
+
// 点击某一天,打开抽屉显示当日 commits
|
|
395
|
+
if (typeof onDayClick === 'function') {
|
|
396
|
+
chart.on('click', (params) => {
|
|
397
|
+
const idx = params.dataIndex
|
|
398
|
+
const date = labels[idx]
|
|
399
|
+
const count = data[idx]
|
|
400
|
+
const dayCommits = commits.filter(
|
|
401
|
+
(c) => new Date(c.date).toISOString().slice(0, 10) === date
|
|
402
|
+
)
|
|
403
|
+
onDayClick(date, count, dayCommits)
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
401
407
|
return chart
|
|
402
408
|
}
|
|
403
409
|
|
|
404
|
-
function
|
|
410
|
+
function showSideBarForWeek(period, weeklyItem, commits = []) {
|
|
411
|
+
// 统一复用通用详情侧栏 DOM
|
|
412
|
+
const sidebar = document.getElementById('dayDetailSidebar')
|
|
413
|
+
const titleEl = document.getElementById('sidebarTitle')
|
|
414
|
+
const contentEl = document.getElementById('sidebarContent')
|
|
415
|
+
|
|
416
|
+
titleEl.innerHTML = `📅 周期:<b>${period}</b>`
|
|
417
|
+
|
|
418
|
+
let html = `
|
|
419
|
+
<div style="padding:6px 0;">
|
|
420
|
+
加班次数:<b>${weeklyItem.outsideWorkCount}</b><br/>
|
|
421
|
+
占比:<b>${(weeklyItem.outsideWorkRate * 100).toFixed(1)}%</b>
|
|
422
|
+
</div>
|
|
423
|
+
<hr/>
|
|
424
|
+
`
|
|
425
|
+
|
|
426
|
+
if (!commits.length) {
|
|
427
|
+
html += `<div style="padding:10px;color:#777;">该周无提交记录</div>`
|
|
428
|
+
} else {
|
|
429
|
+
html += commits
|
|
430
|
+
.map((c) => {
|
|
431
|
+
return `
|
|
432
|
+
<div class="week-commit">
|
|
433
|
+
<div class="meta">👤 <b>${escapeHtml(c.author || 'unknown')}</b> · 🕒 ${
|
|
434
|
+
c.date
|
|
435
|
+
}</div>
|
|
436
|
+
<div class="msg">${escapeHtml((c.message || '').replace(/\n/g, ' '))}</div>
|
|
437
|
+
</div>
|
|
438
|
+
`
|
|
439
|
+
})
|
|
440
|
+
.join('')
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
contentEl.innerHTML = html
|
|
444
|
+
sidebar.classList.add('show')
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function drawWeeklyTrend(weekly, commits, onWeekClick) {
|
|
405
448
|
if (!Array.isArray(weekly) || weekly.length === 0) return null
|
|
406
449
|
|
|
407
450
|
const labels = weekly.map((w) => w.period)
|
|
@@ -409,13 +452,16 @@ function drawWeeklyTrend(weekly) {
|
|
|
409
452
|
const dataCount = weekly.map((w) => w.outsideWorkCount)
|
|
410
453
|
|
|
411
454
|
const el = document.getElementById('weeklyTrendChart')
|
|
412
|
-
// eslint-disable-next-line no-undef
|
|
413
455
|
const chart = echarts.init(el)
|
|
414
456
|
|
|
415
457
|
chart.setOption({
|
|
416
458
|
tooltip: {
|
|
417
459
|
trigger: 'axis',
|
|
418
460
|
formatter: (params) => {
|
|
461
|
+
const pp = params[0]
|
|
462
|
+
const weekItem = weekly[pp.dataIndex]
|
|
463
|
+
const { start, end } = weekItem.range
|
|
464
|
+
|
|
419
465
|
const rate = params.find((p) => p.seriesName.includes('%'))?.data
|
|
420
466
|
const count = params.find((p) => p.seriesName.includes('次数'))?.data
|
|
421
467
|
|
|
@@ -425,22 +471,20 @@ function drawWeeklyTrend(weekly) {
|
|
|
425
471
|
if (rate >= 20) level = '🔴 严重(≥20%)'
|
|
426
472
|
|
|
427
473
|
return `
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
474
|
+
<div style="font-size:13px; line-height:1.5;">
|
|
475
|
+
<b>${params[0].axisValue}</b><br/>
|
|
476
|
+
📅 周区间:<b>${start} ~ ${end}</b><br/>
|
|
477
|
+
加班占比:<b>${rate}%</b><br/>
|
|
478
|
+
加班次数:${count} 次<br/>
|
|
479
|
+
等级:${level}
|
|
480
|
+
</div>
|
|
481
|
+
`
|
|
435
482
|
}
|
|
436
483
|
},
|
|
437
484
|
|
|
438
|
-
legend: {
|
|
439
|
-
top: 10
|
|
440
|
-
},
|
|
485
|
+
legend: { top: 10 },
|
|
441
486
|
|
|
442
487
|
xAxis: { type: 'category', data: labels },
|
|
443
|
-
|
|
444
488
|
yAxis: [
|
|
445
489
|
{ type: 'value', min: 0, max: 100, name: '占比(%)' },
|
|
446
490
|
{ type: 'value', name: '次数', min: 0 }
|
|
@@ -451,26 +495,22 @@ function drawWeeklyTrend(weekly) {
|
|
|
451
495
|
type: 'line',
|
|
452
496
|
name: '加班占比(%)',
|
|
453
497
|
data: dataRate,
|
|
454
|
-
|
|
455
|
-
// ⭐ 区间背景(与 monthly/daily 对齐)
|
|
456
498
|
markArea: {
|
|
457
499
|
data: [
|
|
458
500
|
[
|
|
459
501
|
{ yAxis: 0 },
|
|
460
|
-
{ yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } }
|
|
502
|
+
{ yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } }
|
|
461
503
|
],
|
|
462
504
|
[
|
|
463
505
|
{ yAxis: 10 },
|
|
464
|
-
{ yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } }
|
|
506
|
+
{ yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } }
|
|
465
507
|
],
|
|
466
508
|
[
|
|
467
509
|
{ yAxis: 20 },
|
|
468
|
-
{ yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } }
|
|
510
|
+
{ yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } }
|
|
469
511
|
]
|
|
470
512
|
]
|
|
471
513
|
},
|
|
472
|
-
|
|
473
|
-
// ⭐ 阈值线
|
|
474
514
|
markLine: {
|
|
475
515
|
symbol: ['none', 'arrow'],
|
|
476
516
|
data: [
|
|
@@ -493,17 +533,36 @@ function drawWeeklyTrend(weekly) {
|
|
|
493
533
|
name: '加班次数',
|
|
494
534
|
data: dataCount,
|
|
495
535
|
yAxisIndex: 1,
|
|
496
|
-
|
|
497
|
-
// 次数线使用默认蓝色,避免干扰等级颜色区间
|
|
498
536
|
smooth: true
|
|
499
537
|
}
|
|
500
538
|
]
|
|
501
539
|
})
|
|
502
540
|
|
|
541
|
+
// ⭐ 点击事件:从 commits 过滤该周提交
|
|
542
|
+
chart.on('click', (p) => {
|
|
543
|
+
const idx = p.dataIndex
|
|
544
|
+
const w = weekly[idx]
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
const start = new Date(w.range.start)
|
|
548
|
+
const end = new Date(w.range.end)
|
|
549
|
+
end.setHours(23, 59, 59, 999) // 包含当天
|
|
550
|
+
|
|
551
|
+
const weeklyCommits = commits.filter((c) => {
|
|
552
|
+
const d = new Date(c.date)
|
|
553
|
+
return d >= start && d <= end
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
// 回调交给外面决定如何打开侧栏
|
|
557
|
+
if (typeof onWeekClick === 'function') {
|
|
558
|
+
onWeekClick(w.period, w, weeklyCommits)
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
|
|
503
562
|
return chart
|
|
504
563
|
}
|
|
505
564
|
|
|
506
|
-
function drawMonthlyTrend(monthly) {
|
|
565
|
+
function drawMonthlyTrend(monthly, commits, onMonthClick) {
|
|
507
566
|
if (!Array.isArray(monthly) || monthly.length === 0) return null
|
|
508
567
|
|
|
509
568
|
const labels = monthly.map((m) => m.period)
|
|
@@ -599,10 +658,27 @@ function drawMonthlyTrend(monthly) {
|
|
|
599
658
|
]
|
|
600
659
|
})
|
|
601
660
|
|
|
661
|
+
// 点击某个月份,打开抽屉显示该月的所有 commits
|
|
662
|
+
if (typeof onMonthClick === 'function' && Array.isArray(commits)) {
|
|
663
|
+
chart.on('click', (params) => {
|
|
664
|
+
const idx = params.dataIndex
|
|
665
|
+
const ym = labels[idx] // 'YYYY-MM'
|
|
666
|
+
const monthCommits = commits.filter((c) => {
|
|
667
|
+
const d = new Date(c.date)
|
|
668
|
+
const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(
|
|
669
|
+
2,
|
|
670
|
+
'0'
|
|
671
|
+
)}`
|
|
672
|
+
return m === ym
|
|
673
|
+
})
|
|
674
|
+
onMonthClick(ym, monthCommits.length, monthCommits)
|
|
675
|
+
})
|
|
676
|
+
}
|
|
677
|
+
|
|
602
678
|
return chart
|
|
603
679
|
}
|
|
604
680
|
|
|
605
|
-
function drawLatestHourDaily(latestByDay) {
|
|
681
|
+
function drawLatestHourDaily(latestByDay, commits, onDayClick) {
|
|
606
682
|
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
|
|
607
683
|
|
|
608
684
|
const labels = latestByDay.map((d) => d.date)
|
|
@@ -680,6 +756,8 @@ function drawLatestHourDaily(latestByDay) {
|
|
|
680
756
|
type: 'line',
|
|
681
757
|
name: '每日最晚提交小时',
|
|
682
758
|
data,
|
|
759
|
+
// 让折线在 null 点之间连起来,避免视觉上“断裂”
|
|
760
|
+
connectNulls: true,
|
|
683
761
|
markLine: {
|
|
684
762
|
symbol: ['none', 'arrow'],
|
|
685
763
|
data: [
|
|
@@ -715,10 +793,28 @@ function drawLatestHourDaily(latestByDay) {
|
|
|
715
793
|
]
|
|
716
794
|
})
|
|
717
795
|
|
|
796
|
+
// 点击某一天的最晚提交时间点,打开抽屉显示该日 commits
|
|
797
|
+
if (typeof onDayClick === 'function' && Array.isArray(commits)) {
|
|
798
|
+
// 预聚合:按天收集 commits
|
|
799
|
+
const dayCommitsMap = {}
|
|
800
|
+
commits.forEach((c) => {
|
|
801
|
+
const d = new Date(c.date).toISOString().slice(0, 10)
|
|
802
|
+
if (!dayCommitsMap[d]) dayCommitsMap[d] = []
|
|
803
|
+
dayCommitsMap[d].push(c)
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
chart.on('click', (params) => {
|
|
807
|
+
const idx = params.dataIndex
|
|
808
|
+
const date = labels[idx]
|
|
809
|
+
const list = dayCommitsMap[date] || []
|
|
810
|
+
onDayClick(date, list.length, list)
|
|
811
|
+
})
|
|
812
|
+
}
|
|
813
|
+
|
|
718
814
|
return chart
|
|
719
815
|
}
|
|
720
816
|
|
|
721
|
-
function drawDailySeverity(latestByDay) {
|
|
817
|
+
function drawDailySeverity(latestByDay, commits, onDayClick) {
|
|
722
818
|
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
|
|
723
819
|
|
|
724
820
|
const labels = latestByDay.map((d) => d.date)
|
|
@@ -730,7 +826,11 @@ function drawDailySeverity(latestByDay) {
|
|
|
730
826
|
: (d.latestHour ?? null)
|
|
731
827
|
)
|
|
732
828
|
|
|
733
|
-
|
|
829
|
+
// 若某天 latestHourNormalized 为空,表示「没有下班后到次日上班前的提交」,
|
|
830
|
+
// 这里按 0 小时加班处理,保证折线连续。
|
|
831
|
+
const sev = raw.map((v) =>
|
|
832
|
+
v == null ? 0 : Math.max(0, Number(v) - endH)
|
|
833
|
+
)
|
|
734
834
|
|
|
735
835
|
const el = document.getElementById('dailySeverityChart')
|
|
736
836
|
// eslint-disable-next-line no-undef
|
|
@@ -746,15 +846,6 @@ function drawDailySeverity(latestByDay) {
|
|
|
746
846
|
const overtime = p.data
|
|
747
847
|
const rawHour = raw[p.dataIndex] // 原始 latestHour 或 latestHourNormalized
|
|
748
848
|
|
|
749
|
-
if (overtime == null) {
|
|
750
|
-
return `
|
|
751
|
-
<div style="font-size:13px;">
|
|
752
|
-
<b>${date}</b><br/>
|
|
753
|
-
无数据
|
|
754
|
-
</div>
|
|
755
|
-
`
|
|
756
|
-
}
|
|
757
|
-
|
|
758
849
|
return `
|
|
759
850
|
<div style="font-size:13px;">
|
|
760
851
|
<b>${date}</b><br/>
|
|
@@ -781,6 +872,8 @@ function drawDailySeverity(latestByDay) {
|
|
|
781
872
|
type: 'line',
|
|
782
873
|
name: '超过下班小时数',
|
|
783
874
|
data: sev,
|
|
875
|
+
// 连续显示 0 小时加班的日期,避免折线断开
|
|
876
|
+
connectNulls: true,
|
|
784
877
|
|
|
785
878
|
// ⭐ 加班区域背景
|
|
786
879
|
markArea: {
|
|
@@ -828,6 +921,23 @@ function drawDailySeverity(latestByDay) {
|
|
|
828
921
|
]
|
|
829
922
|
})
|
|
830
923
|
|
|
924
|
+
// 点击某一天的「超过下班小时数」点,打开抽屉显示该日 commits
|
|
925
|
+
if (typeof onDayClick === 'function' && Array.isArray(commits)) {
|
|
926
|
+
const dayCommitsMap = {}
|
|
927
|
+
commits.forEach((c) => {
|
|
928
|
+
const d = new Date(c.date).toISOString().slice(0, 10)
|
|
929
|
+
if (!dayCommitsMap[d]) dayCommitsMap[d] = []
|
|
930
|
+
dayCommitsMap[d].push(c)
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
chart.on('click', (params) => {
|
|
934
|
+
const idx = params.dataIndex
|
|
935
|
+
const date = labels[idx]
|
|
936
|
+
const list = dayCommitsMap[date] || []
|
|
937
|
+
onDayClick(date, list.length, list)
|
|
938
|
+
})
|
|
939
|
+
}
|
|
940
|
+
|
|
831
941
|
return chart
|
|
832
942
|
}
|
|
833
943
|
|
|
@@ -1002,8 +1112,8 @@ function showDayDetailSidebar(date, count, commits) {
|
|
|
1002
1112
|
(c) => `
|
|
1003
1113
|
<div style="margin-bottom:12px;">
|
|
1004
1114
|
<div>👤 <b>${c.author}</b></div>
|
|
1005
|
-
<div>🕒 ${c.time}</div>
|
|
1006
|
-
<div>💬 ${c.msg}</div>
|
|
1115
|
+
<div>🕒 ${c.time || c.date}</div>
|
|
1116
|
+
<div>💬 ${c.msg ||c.message}</div>
|
|
1007
1117
|
</div>
|
|
1008
1118
|
<hr/>
|
|
1009
1119
|
`
|
|
@@ -1013,21 +1123,59 @@ function showDayDetailSidebar(date, count, commits) {
|
|
|
1013
1123
|
sidebar.classList.add('show')
|
|
1014
1124
|
}
|
|
1015
1125
|
|
|
1016
|
-
// 关闭按钮
|
|
1017
|
-
document.getElementById('sidebarClose').onclick = () => {
|
|
1018
|
-
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
1126
|
function renderKpi(stats) {
|
|
1022
1127
|
const el = document.getElementById('kpiContent')
|
|
1023
1128
|
if (!el || !stats) return
|
|
1024
1129
|
const latest = stats.latestCommit
|
|
1025
1130
|
const latestHour = stats.latestCommitHour
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
stats.latestOutsideCommitHour ??
|
|
1029
|
-
(latestOut ? new Date(latestOut.date).getHours() : null)
|
|
1131
|
+
|
|
1132
|
+
// 使用 cutoff + 上下班时间,重新在全部 commits 中计算「加班最晚一次提交」
|
|
1030
1133
|
const cutoff = window.__overnightCutoff ?? 6
|
|
1134
|
+
const startHour =
|
|
1135
|
+
(typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
1136
|
+
? stats.startHour
|
|
1137
|
+
: 9)
|
|
1138
|
+
const endHour =
|
|
1139
|
+
(typeof stats.endHour === 'number' && stats.endHour >= 0
|
|
1140
|
+
? stats.endHour
|
|
1141
|
+
: window.__overtimeEndHour ?? 18)
|
|
1142
|
+
|
|
1143
|
+
let latestOut = null
|
|
1144
|
+
let latestOutHour = null
|
|
1145
|
+
let maxSeverity = -1
|
|
1146
|
+
|
|
1147
|
+
if (Array.isArray(commitsAll) && commitsAll.length > 0) {
|
|
1148
|
+
commitsAll.forEach((c) => {
|
|
1149
|
+
const d = new Date(c.date)
|
|
1150
|
+
if (!d || Number.isNaN(d.valueOf())) return
|
|
1151
|
+
const h = d.getHours()
|
|
1152
|
+
|
|
1153
|
+
// 只看「当日下班后」以及「次日凌晨 cutoff 之前,且仍在上班前」的提交
|
|
1154
|
+
let sev = null
|
|
1155
|
+
if (h >= endHour && h < 24) {
|
|
1156
|
+
// 当晚:直接按 h - endHour 计算
|
|
1157
|
+
sev = h - endHour
|
|
1158
|
+
} else if (h >= 0 && h < cutoff && h < startHour) {
|
|
1159
|
+
// 次日凌晨:视作跨天,加上 24
|
|
1160
|
+
sev = 24 - endHour + h
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
if (sev != null && sev >= 0 && sev > maxSeverity) {
|
|
1164
|
+
maxSeverity = sev
|
|
1165
|
+
latestOut = c
|
|
1166
|
+
latestOutHour = h
|
|
1167
|
+
}
|
|
1168
|
+
})
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// 若按 cutoff 没算出结果,则退回到原来的 stats.latestOutsideCommit
|
|
1172
|
+
if (!latestOut && stats.latestOutsideCommit) {
|
|
1173
|
+
latestOut = stats.latestOutsideCommit
|
|
1174
|
+
latestOutHour =
|
|
1175
|
+
stats.latestOutsideCommitHour ??
|
|
1176
|
+
(latestOut ? new Date(latestOut.date).getHours() : null)
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1031
1179
|
const html = [
|
|
1032
1180
|
`<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
1181
|
`<div class="hr"></div>`,
|
|
@@ -1050,7 +1198,7 @@ function groupCommitsByHour(commits) {
|
|
|
1050
1198
|
return byHour
|
|
1051
1199
|
}
|
|
1052
1200
|
|
|
1053
|
-
|
|
1201
|
+
async function main() {
|
|
1054
1202
|
const { commits, stats, weekly, monthly, latestByDay, config } =
|
|
1055
1203
|
await loadData()
|
|
1056
1204
|
commitsAll = commits
|
|
@@ -1064,21 +1212,145 @@ function groupCommitsByHour(commits) {
|
|
|
1064
1212
|
initTableControls()
|
|
1065
1213
|
updatePager()
|
|
1066
1214
|
renderCommitsTablePage()
|
|
1067
|
-
// 使用举例
|
|
1068
|
-
const hourCommitsDetail = groupCommitsByHour(commits)
|
|
1069
1215
|
|
|
1070
1216
|
drawHourlyOvertime(stats, (hour, count) => {
|
|
1217
|
+
// 使用举例
|
|
1218
|
+
const hourCommitsDetail = groupCommitsByHour(commits)
|
|
1071
1219
|
// 将 commit 列表传给侧栏(若没有详情,则传空数组)
|
|
1072
1220
|
showSideBarForHour(hour, hourCommitsDetail[hour] || [])
|
|
1073
1221
|
})
|
|
1074
1222
|
drawOutsideVsInside(stats)
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1223
|
+
|
|
1224
|
+
// 按日提交趋势:点击某天打开抽屉,显示当日所有 commits
|
|
1225
|
+
drawDailyTrend(commits, showDayDetailSidebar)
|
|
1226
|
+
|
|
1227
|
+
// 周趋势:保持原有点击行为(显示该周详情)
|
|
1228
|
+
drawWeeklyTrend(weekly, commits, showSideBarForWeek)
|
|
1229
|
+
|
|
1230
|
+
// 月趋势(加班占比):点击某个月打开抽屉,显示该月所有 commits
|
|
1231
|
+
drawMonthlyTrend(monthly, commits, showDayDetailSidebar)
|
|
1232
|
+
|
|
1233
|
+
// 每日最晚提交时间(小时):点击某天打开抽屉,显示当日所有 commits
|
|
1234
|
+
drawLatestHourDaily(latestByDay, commits, showDayDetailSidebar)
|
|
1235
|
+
|
|
1236
|
+
// 每日超过下班的小时数:点击某天打开抽屉,显示当日所有 commits
|
|
1237
|
+
drawDailySeverity(latestByDay, commits, showDayDetailSidebar)
|
|
1238
|
+
|
|
1080
1239
|
const daily = drawDailyTrendSeverity(commits, weekly, showDayDetailSidebar)
|
|
1081
1240
|
|
|
1082
1241
|
console.log('最累的一天:', daily.analysis.mostTiredDay)
|
|
1242
|
+
computeAndRenderLatestOvertime(latestByDay)
|
|
1083
1243
|
renderKpi(stats)
|
|
1084
|
-
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// 基于 latestByDay + cutoff/endHour 统计「最晚加班的一天 / 一周 / 一月」
|
|
1247
|
+
function computeAndRenderLatestOvertime(latestByDay) {
|
|
1248
|
+
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return
|
|
1249
|
+
|
|
1250
|
+
const endH = window.__overtimeEndHour || 18
|
|
1251
|
+
|
|
1252
|
+
// 每天的 latestHourNormalized → 超出下班的小时数
|
|
1253
|
+
const dailyOvertime = latestByDay
|
|
1254
|
+
.map((d) => {
|
|
1255
|
+
const v =
|
|
1256
|
+
typeof d.latestHourNormalized === 'number'
|
|
1257
|
+
? d.latestHourNormalized
|
|
1258
|
+
: typeof d.latestHour === 'number'
|
|
1259
|
+
? d.latestHour
|
|
1260
|
+
: null
|
|
1261
|
+
if (v == null) return null
|
|
1262
|
+
const overtime = Math.max(0, Number(v) - endH)
|
|
1263
|
+
return { date: d.date, overtime, raw: v }
|
|
1264
|
+
})
|
|
1265
|
+
.filter(Boolean)
|
|
1266
|
+
|
|
1267
|
+
if (!dailyOvertime.length) return
|
|
1268
|
+
|
|
1269
|
+
// 1) 最晚加班的一天(超出下班小时数最大,若相同取日期更晚)
|
|
1270
|
+
const dailySorted = [...dailyOvertime].sort((a, b) => {
|
|
1271
|
+
if (b.overtime !== a.overtime) return b.overtime - a.overtime
|
|
1272
|
+
return new Date(b.date) - new Date(a.date)
|
|
1273
|
+
})
|
|
1274
|
+
const worstDay = dailySorted[0]
|
|
1275
|
+
const dayEl = document.getElementById('latestOvertimeDay')
|
|
1276
|
+
if (dayEl) {
|
|
1277
|
+
dayEl.innerHTML = `⏰ 最晚加班的一天:<b>${worstDay.date}</b>(超过下班 <b>${worstDay.overtime.toFixed(
|
|
1278
|
+
2
|
|
1279
|
+
)}</b> 小时,逻辑时间约 ${worstDay.raw.toFixed(2)} 点)`
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// 工具:根据日期字符串计算 ISO 周 key:YYYY-Www
|
|
1283
|
+
const getIsoWeekKey = (dStr) => {
|
|
1284
|
+
const d = new Date(dStr)
|
|
1285
|
+
if (Number.isNaN(d.valueOf())) return null
|
|
1286
|
+
const target = new Date(
|
|
1287
|
+
Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())
|
|
1288
|
+
)
|
|
1289
|
+
const dayNum = target.getUTCDay() || 7 // Sunday=0
|
|
1290
|
+
target.setUTCDate(target.getUTCDate() + 4 - dayNum)
|
|
1291
|
+
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
|
|
1292
|
+
const weekNo = Math.ceil(((target - yearStart) / 86400000 + 1) / 7)
|
|
1293
|
+
const year = target.getUTCFullYear()
|
|
1294
|
+
return `${year}-W${String(weekNo).padStart(2, '0')}`
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// 2) 按周聚合:每周取「该周内任意一天的最大加班时长」
|
|
1298
|
+
const weekMap = new Map()
|
|
1299
|
+
dailyOvertime.forEach((d) => {
|
|
1300
|
+
const key = getIsoWeekKey(d.date)
|
|
1301
|
+
if (!key) return
|
|
1302
|
+
const cur = weekMap.get(key)
|
|
1303
|
+
if (!cur || d.overtime > cur.overtime) {
|
|
1304
|
+
weekMap.set(key, d)
|
|
1305
|
+
}
|
|
1306
|
+
})
|
|
1307
|
+
|
|
1308
|
+
if (weekMap.size) {
|
|
1309
|
+
const weeks = Array.from(weekMap.entries()).sort((a, b) => {
|
|
1310
|
+
if (b[1].overtime !== a[1].overtime) return b[1].overtime - a[1].overtime
|
|
1311
|
+
return new Date(b[1].date) - new Date(a[1].date)
|
|
1312
|
+
})
|
|
1313
|
+
const [weekKey, weekInfo] = weeks[0]
|
|
1314
|
+
const weekEl = document.getElementById('latestOvertimeWeek')
|
|
1315
|
+
if (weekEl) {
|
|
1316
|
+
weekEl.innerHTML = `⏰ 最晚加班的一周:<b>${weekKey}</b>(代表日期 ${weekInfo.date},超过下班 <b>${weekInfo.overtime.toFixed(
|
|
1317
|
+
2
|
|
1318
|
+
)}</b> 小时)`
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// 3) 按月聚合:每月取「该月任意一天的最大加班时长」
|
|
1323
|
+
const monthMap = new Map()
|
|
1324
|
+
dailyOvertime.forEach((d) => {
|
|
1325
|
+
const dt = new Date(d.date)
|
|
1326
|
+
if (Number.isNaN(dt.valueOf())) return
|
|
1327
|
+
const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(
|
|
1328
|
+
2,
|
|
1329
|
+
'0'
|
|
1330
|
+
)}`
|
|
1331
|
+
const cur = monthMap.get(key)
|
|
1332
|
+
if (!cur || d.overtime > cur.overtime) {
|
|
1333
|
+
monthMap.set(key, d)
|
|
1334
|
+
}
|
|
1335
|
+
})
|
|
1336
|
+
|
|
1337
|
+
if (monthMap.size) {
|
|
1338
|
+
const months = Array.from(monthMap.entries()).sort((a, b) => {
|
|
1339
|
+
if (b[1].overtime !== a[1].overtime) return b[1].overtime - a[1].overtime
|
|
1340
|
+
return new Date(b[1].date) - new Date(a[1].date)
|
|
1341
|
+
})
|
|
1342
|
+
const [monthKey, monthInfo] = months[0]
|
|
1343
|
+
const monthEl = document.getElementById('latestOvertimeMonth')
|
|
1344
|
+
if (monthEl) {
|
|
1345
|
+
monthEl.innerHTML = `⏰ 最晚加班的月份:<b>${monthKey}</b>(代表日期 ${monthInfo.date},超过下班 <b>${monthInfo.overtime.toFixed(
|
|
1346
|
+
2
|
|
1347
|
+
)}</b> 小时)`
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// 关闭按钮
|
|
1353
|
+
document.getElementById('sidebarClose').onclick = () => {
|
|
1354
|
+
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
1355
|
+
}
|
|
1356
|
+
main()
|
package/web/index.html
CHANGED
|
@@ -56,6 +56,9 @@
|
|
|
56
56
|
<div id="mostTiredDay"></div>
|
|
57
57
|
<div id="mostTiredWeek"></div>
|
|
58
58
|
<div id="mostTiredMonth"></div>
|
|
59
|
+
<div id="latestOvertimeDay"></div>
|
|
60
|
+
<div id="latestOvertimeWeek"></div>
|
|
61
|
+
<div id="latestOvertimeMonth"></div>
|
|
59
62
|
</section>
|
|
60
63
|
|
|
61
64
|
<section class="table-card">
|
|
@@ -98,26 +101,15 @@
|
|
|
98
101
|
<code>/data/</code>.</small
|
|
99
102
|
>
|
|
100
103
|
</footer>
|
|
101
|
-
<!--
|
|
104
|
+
<!-- 通用:右侧滑出的详情侧栏(小时 / 天 / 周 复用同一个 DOM) -->
|
|
102
105
|
<div id="dayDetailSidebar" class="sidebar">
|
|
103
106
|
<div class="sidebar-header">
|
|
104
107
|
<span id="sidebarTitle"></span>
|
|
105
108
|
<button id="sidebarClose">×</button>
|
|
106
109
|
</div>
|
|
107
|
-
|
|
108
110
|
<div id="sidebarContent" class="sidebar-content"></div>
|
|
109
111
|
</div>
|
|
110
112
|
|
|
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
|
-
|
|
121
113
|
<script type="module" src="/app.js"></script>
|
|
122
114
|
</body>
|
|
123
115
|
</html>
|