wukong-gitlog-cli 1.0.15 → 1.0.17
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 +21 -0
- package/package.json +9 -8
- package/src/cli.mjs +31 -2
- package/src/excel.mjs +116 -46
- package/src/git.mjs +33 -7
- package/src/json.mjs +5 -0
- package/src/stats-text.mjs +51 -0
- package/src/stats.mjs +44 -0
- package/src/text.mjs +37 -42
- package/web/app.js +177 -79
- package/web/index.html +13 -0
- package/web/static/style.css +122 -22
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
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.17](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.16...v1.0.17) (2025-12-05)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* 🎸 change line total ([bf01655](https://github.com/tomatobybike/wukong-gitlog-cli/commit/bf01655f3721b87d4bb36e74a7ce6dadf5e7ee94))
|
|
11
|
+
* 🎸 develop Changes totals ([fb64986](https://github.com/tomatobybike/wukong-gitlog-cli/commit/fb64986cfde215fc58ce7b1b66cbc8bdbf280627))
|
|
12
|
+
|
|
13
|
+
### [1.0.16](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.15...v1.0.16) (2025-12-03)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* 🎸 sider bar UI ([e3a48ab](https://github.com/tomatobybike/wukong-gitlog-cli/commit/e3a48abe5b0148fe0d9dcd42b7b028cad53d4c94))
|
|
19
|
+
* 🎸 ui show ([e9eba58](https://github.com/tomatobybike/wukong-gitlog-cli/commit/e9eba5823333545d598be363b58eca1f6ecfa6ed))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* 🐛 makeline clickEvent ([4ad6378](https://github.com/tomatobybike/wukong-gitlog-cli/commit/4ad6378d88aca2dde6083efde844730443228e48))
|
|
25
|
+
|
|
5
26
|
### [1.0.15](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.14...v1.0.15) (2025-12-03)
|
|
6
27
|
|
|
7
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wukong-gitlog-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.17",
|
|
4
4
|
"description": "Advanced Git commit log exporter with Excel/JSON/TXT output, grouping, stats and CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"git",
|
|
@@ -90,20 +90,19 @@
|
|
|
90
90
|
]
|
|
91
91
|
},
|
|
92
92
|
"dependencies": {
|
|
93
|
+
"boxen": "8.0.1",
|
|
94
|
+
"chalk": "5.6.2",
|
|
93
95
|
"commander": "12.1.0",
|
|
94
|
-
"dayjs": "1.11.19",
|
|
95
96
|
"date-holidays": "2.1.1",
|
|
96
|
-
"
|
|
97
|
+
"dayjs": "1.11.19",
|
|
97
98
|
"exceljs": "4.4.0",
|
|
98
|
-
"zx": "7.2.4",
|
|
99
99
|
"is-online": "12.0.2",
|
|
100
|
-
"
|
|
101
|
-
"
|
|
100
|
+
"ora": "9.0.0",
|
|
101
|
+
"string-width": "5.1.2",
|
|
102
|
+
"zx": "7.2.4"
|
|
102
103
|
},
|
|
103
104
|
"devDependencies": {
|
|
104
105
|
"@trivago/prettier-plugin-sort-imports": "5.2.2",
|
|
105
|
-
"husky": "^9.1.7",
|
|
106
|
-
"lint-staged": "^16.2.7",
|
|
107
106
|
"eslint": "8.57.1",
|
|
108
107
|
"eslint-config-airbnb-base": "15.0.0",
|
|
109
108
|
"eslint-config-prettier": "10.1.8",
|
|
@@ -111,6 +110,8 @@
|
|
|
111
110
|
"eslint-plugin-import": "2.32.0",
|
|
112
111
|
"eslint-plugin-prettier": "5.5.4",
|
|
113
112
|
"eslint-plugin-simple-import-sort": "12.1.1",
|
|
113
|
+
"husky": "^9.1.7",
|
|
114
|
+
"lint-staged": "^16.2.7",
|
|
114
115
|
"prettier": "3.6.2",
|
|
115
116
|
"prettier-plugin-packagejson": "2.5.19",
|
|
116
117
|
"sort-package-json": "3.4.0",
|
package/src/cli.mjs
CHANGED
|
@@ -3,12 +3,18 @@ import { Command } from 'commander'
|
|
|
3
3
|
import dayjs from 'dayjs'
|
|
4
4
|
import isoWeek from 'dayjs/plugin/isoWeek.js'
|
|
5
5
|
import fs from 'fs'
|
|
6
|
+
import ora from 'ora'
|
|
6
7
|
import path from 'path'
|
|
7
8
|
import { fileURLToPath } from 'url'
|
|
8
9
|
|
|
9
10
|
import { CLI_NAME } from './constants/index.mjs'
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
exportExcel,
|
|
13
|
+
exportExcelAuthorChangeStats,
|
|
14
|
+
exportExcelPerPeriodSheets
|
|
15
|
+
} from './excel.mjs'
|
|
11
16
|
import { getGitLogs } from './git.mjs'
|
|
17
|
+
import { renderAuthorChangesJson } from './json.mjs'
|
|
12
18
|
import {
|
|
13
19
|
analyzeOvertime,
|
|
14
20
|
renderOvertimeCsv,
|
|
@@ -16,7 +22,7 @@ import {
|
|
|
16
22
|
renderOvertimeText
|
|
17
23
|
} from './overtime.mjs'
|
|
18
24
|
import { startServer } from './server.mjs'
|
|
19
|
-
import { renderText } from './text.mjs'
|
|
25
|
+
import { renderChangedLinesText, renderText } from './text.mjs'
|
|
20
26
|
import { checkUpdateWithPatch } from './utils/checkUpdate.mjs'
|
|
21
27
|
import {
|
|
22
28
|
groupRecords,
|
|
@@ -194,6 +200,8 @@ const main = async () => {
|
|
|
194
200
|
return
|
|
195
201
|
}
|
|
196
202
|
|
|
203
|
+
const spinner = ora('Loading...').start()
|
|
204
|
+
|
|
197
205
|
let records = await getGitLogs(opts)
|
|
198
206
|
|
|
199
207
|
// compute output directory root if user provided one or wants parent
|
|
@@ -342,6 +350,13 @@ const main = async () => {
|
|
|
342
350
|
const dataCommitsFile = outputFilePath('data/commits.mjs', outDir)
|
|
343
351
|
const commitsModule = `export default ${JSON.stringify(records, null, 2)};\n`
|
|
344
352
|
writeTextFile(dataCommitsFile, commitsModule)
|
|
353
|
+
|
|
354
|
+
const dataCommitsChangedFile = outputFilePath('data/author-changes.mjs', outDir)
|
|
355
|
+
const jsonText = renderAuthorChangesJson(records)
|
|
356
|
+
|
|
357
|
+
const commitsChangedModule = `export default ${JSON.stringify(jsonText, null, 2)};\n`
|
|
358
|
+
writeTextFile(dataCommitsChangedFile, commitsChangedModule)
|
|
359
|
+
|
|
345
360
|
const dataStatsFile = outputFilePath('data/overtime-stats.mjs', outDir)
|
|
346
361
|
const statsModule = `export default ${JSON.stringify(stats, null, 2)};\n`
|
|
347
362
|
writeTextFile(dataStatsFile, statsModule)
|
|
@@ -770,7 +785,10 @@ const main = async () => {
|
|
|
770
785
|
const file = opts.out || 'commits.json'
|
|
771
786
|
const filepath = outputFilePath(file, outDir)
|
|
772
787
|
writeJSON(filepath, groups || records)
|
|
788
|
+
const jsonText = renderAuthorChangesJson(records)
|
|
789
|
+
writeJSON(outputFilePath('author-changes.json', outDir), jsonText)
|
|
773
790
|
console.log(chalk.green(`JSON 已导出: ${filepath}`))
|
|
791
|
+
spinner.succeed('Done')
|
|
774
792
|
return
|
|
775
793
|
}
|
|
776
794
|
|
|
@@ -779,8 +797,13 @@ const main = async () => {
|
|
|
779
797
|
const filepath = outputFilePath(file, outDir)
|
|
780
798
|
const text = renderText(records, groups, { showGerrit: !!opts.gerrit })
|
|
781
799
|
writeTextFile(filepath, text)
|
|
800
|
+
writeTextFile(
|
|
801
|
+
outputFilePath('author-changes.text', outDir),
|
|
802
|
+
renderChangedLinesText(records)
|
|
803
|
+
)
|
|
782
804
|
console.log(text)
|
|
783
805
|
console.log(chalk.green(`文本已导出: ${filepath}`))
|
|
806
|
+
spinner.succeed('Done')
|
|
784
807
|
return
|
|
785
808
|
}
|
|
786
809
|
|
|
@@ -794,11 +817,17 @@ const main = async () => {
|
|
|
794
817
|
stats: opts.stats,
|
|
795
818
|
gerrit: opts.gerrit
|
|
796
819
|
})
|
|
820
|
+
await exportExcelAuthorChangeStats(
|
|
821
|
+
records,
|
|
822
|
+
outputFilePath('author_stats.xlsx', outDir)
|
|
823
|
+
)
|
|
797
824
|
const text = renderText(records, groups)
|
|
798
825
|
writeTextFile(txtPath, text)
|
|
799
826
|
console.log(chalk.green(`Excel 已导出: ${excelPath}`))
|
|
800
827
|
console.log(chalk.green(`文本已自动导出: ${txtPath}`))
|
|
828
|
+
spinner.succeed('Done')
|
|
801
829
|
}
|
|
830
|
+
|
|
802
831
|
await autoCheckUpdate()
|
|
803
832
|
}
|
|
804
833
|
|
package/src/excel.mjs
CHANGED
|
@@ -1,97 +1,167 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import dayjs from 'dayjs'
|
|
2
|
+
import ExcelJS from 'exceljs'
|
|
3
|
+
import { buildAuthorChangeStats } from './stats.mjs'
|
|
4
|
+
|
|
3
5
|
|
|
4
6
|
export async function exportExcel(records, groups, options = {}) {
|
|
5
|
-
const { file, stats, gerrit } = options
|
|
7
|
+
const { file, stats, gerrit } = options
|
|
6
8
|
|
|
7
|
-
const wb = new ExcelJS.Workbook()
|
|
8
|
-
const ws = wb.addWorksheet('Commits')
|
|
9
|
+
const wb = new ExcelJS.Workbook()
|
|
10
|
+
const ws = wb.addWorksheet('Commits')
|
|
9
11
|
|
|
10
12
|
const cols = [
|
|
11
13
|
{ header: 'Hash', key: 'hash', width: 12 },
|
|
12
14
|
{ header: 'Author', key: 'author', width: 20 },
|
|
13
15
|
{ header: 'Email', key: 'email', width: 30 },
|
|
14
16
|
{ header: 'Date', key: 'date', width: 20 },
|
|
15
|
-
{ header: 'Message', key: 'message', width: 80 }
|
|
16
|
-
|
|
17
|
+
{ header: 'Message', key: 'message', width: 80 },
|
|
18
|
+
{ header: 'Changed', key: 'changed', width: 12 } // ★ 新增 changed 列
|
|
19
|
+
]
|
|
17
20
|
|
|
18
21
|
if (gerrit) {
|
|
19
|
-
cols.push({ header: 'Gerrit', key: 'gerrit', width: 50 })
|
|
22
|
+
cols.push({ header: 'Gerrit', key: 'gerrit', width: 50 })
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
ws.columns = cols
|
|
25
|
+
ws.columns = cols
|
|
23
26
|
|
|
24
|
-
(groups ? Object.values(groups).flat() : records).forEach(r =>
|
|
27
|
+
;(groups ? Object.values(groups).flat() : records).forEach((r) =>
|
|
25
28
|
ws.addRow(r)
|
|
26
|
-
)
|
|
29
|
+
)
|
|
27
30
|
|
|
28
|
-
ws.autoFilter = {
|
|
31
|
+
ws.autoFilter = {
|
|
32
|
+
from: { row: 1, column: 1 },
|
|
33
|
+
to: { row: 1, column: cols.length }
|
|
34
|
+
}
|
|
29
35
|
|
|
30
36
|
// --- stats sheet ---
|
|
31
37
|
if (stats) {
|
|
32
|
-
const statWs = wb.addWorksheet('Stats')
|
|
38
|
+
const statWs = wb.addWorksheet('Stats')
|
|
33
39
|
|
|
34
|
-
const map = {}
|
|
40
|
+
const map = {}
|
|
35
41
|
|
|
36
|
-
records.forEach(r => {
|
|
37
|
-
const d = dayjs(r.date).format('YYYY-MM-DD')
|
|
38
|
-
map[d] = (map[d] || 0) + 1
|
|
39
|
-
})
|
|
42
|
+
records.forEach((r) => {
|
|
43
|
+
const d = dayjs(r.date).format('YYYY-MM-DD')
|
|
44
|
+
map[d] = (map[d] || 0) + 1
|
|
45
|
+
})
|
|
40
46
|
|
|
41
47
|
statWs.columns = [
|
|
42
48
|
{ header: 'Date', key: 'date', width: 15 },
|
|
43
49
|
{ header: 'Commits', key: 'count', width: 15 }
|
|
44
|
-
]
|
|
50
|
+
]
|
|
45
51
|
|
|
46
52
|
Object.entries(map).forEach(([d, cnt]) =>
|
|
47
53
|
statWs.addRow({ date: d, count: cnt })
|
|
48
|
-
)
|
|
54
|
+
)
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
await wb.xlsx.writeFile(file)
|
|
57
|
+
await wb.xlsx.writeFile(file)
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
export async function exportExcelPerPeriodSheets(groups, file, options = {}) {
|
|
55
|
-
|
|
56
|
-
const { stats, gerrit } = options;
|
|
61
|
+
const { stats, gerrit } = options
|
|
57
62
|
|
|
58
|
-
const wb = new ExcelJS.Workbook()
|
|
63
|
+
const wb = new ExcelJS.Workbook()
|
|
59
64
|
|
|
60
65
|
const cols = [
|
|
61
66
|
{ header: 'Hash', key: 'hash', width: 12 },
|
|
62
67
|
{ header: 'Author', key: 'author', width: 20 },
|
|
63
68
|
{ header: 'Email', key: 'email', width: 30 },
|
|
64
69
|
{ header: 'Date', key: 'date', width: 20 },
|
|
65
|
-
{ header: 'Message', key: 'message', width: 80 }
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
{ header: 'Message', key: 'message', width: 80 },
|
|
71
|
+
{ header: 'Changed', key: 'changed', width: 12 } // ★ 新增 changed 列
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
if (gerrit) cols.push({ header: 'Gerrit', key: 'gerrit', width: 50 })
|
|
75
|
+
|
|
76
|
+
const keys = Object.keys(groups).sort()
|
|
68
77
|
|
|
69
|
-
const keys = Object.keys(groups).sort();
|
|
70
78
|
keys.forEach((k) => {
|
|
71
|
-
const ws = wb.addWorksheet(String(k).slice(0, 31))
|
|
72
|
-
ws.columns = cols
|
|
73
|
-
groups[k].forEach(r => ws.addRow(r))
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
const ws = wb.addWorksheet(String(k).slice(0, 31))
|
|
80
|
+
ws.columns = cols
|
|
81
|
+
groups[k].forEach((r) => ws.addRow(r))
|
|
82
|
+
|
|
83
|
+
ws.autoFilter = {
|
|
84
|
+
from: { row: 1, column: 1 },
|
|
85
|
+
to: { row: 1, column: cols.length }
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// summary sheet
|
|
90
|
+
const summary = wb.addWorksheet('Summary')
|
|
79
91
|
summary.columns = [
|
|
80
92
|
{ header: 'Period', key: 'period', width: 20 },
|
|
81
93
|
{ header: 'Commits', key: 'count', width: 12 }
|
|
82
|
-
]
|
|
83
|
-
keys.forEach(k => summary.addRow({ period: k, count: groups[k].length }))
|
|
94
|
+
]
|
|
95
|
+
keys.forEach((k) => summary.addRow({ period: k, count: groups[k].length }))
|
|
84
96
|
|
|
85
|
-
await wb.xlsx.writeFile(file)
|
|
97
|
+
await wb.xlsx.writeFile(file)
|
|
86
98
|
}
|
|
87
99
|
|
|
88
|
-
export async function exportExcelPerPeriodFiles(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
export async function exportExcelPerPeriodFiles(
|
|
101
|
+
groups,
|
|
102
|
+
dir,
|
|
103
|
+
filePrefix,
|
|
104
|
+
options = {}
|
|
105
|
+
) {
|
|
106
|
+
const keys = Object.keys(groups).sort()
|
|
93
107
|
for (const k of keys) {
|
|
94
|
-
const perFile = `${dir}/overtime_${filePrefix}_${k}.xlsx
|
|
95
|
-
await exportExcel(groups[k], null, {
|
|
108
|
+
const perFile = `${dir}/overtime_${filePrefix}_${k}.xlsx`
|
|
109
|
+
await exportExcel(groups[k], null, {
|
|
110
|
+
file: perFile,
|
|
111
|
+
stats: options.stats,
|
|
112
|
+
gerrit: options.gerrit
|
|
113
|
+
})
|
|
96
114
|
}
|
|
97
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 导出作者的日/周/月 changed 统计
|
|
119
|
+
* @param {*} records 原始 commits(包含 author/date/changed)
|
|
120
|
+
* @param {*} file 输出文件
|
|
121
|
+
*/
|
|
122
|
+
export async function exportExcelAuthorChangeStats(records, file) {
|
|
123
|
+
const stats = buildAuthorChangeStats(records)
|
|
124
|
+
|
|
125
|
+
const wb = new ExcelJS.Workbook()
|
|
126
|
+
|
|
127
|
+
//-------------------------------------------
|
|
128
|
+
// 工具方法:创建一个统计 sheet
|
|
129
|
+
//-------------------------------------------
|
|
130
|
+
function addStatSheet(title, dataMap) {
|
|
131
|
+
const ws = wb.addWorksheet(title)
|
|
132
|
+
|
|
133
|
+
ws.columns = [
|
|
134
|
+
{ header: 'Author', key: 'author', width: 20 },
|
|
135
|
+
{ header: 'Period', key: 'period', width: 20 },
|
|
136
|
+
{ header: 'Changed', key: 'changed', width: 12 }
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
for (const [author, periodMap] of Object.entries(dataMap)) {
|
|
140
|
+
const periods = Object.keys(periodMap).sort()
|
|
141
|
+
for (const p of periods) {
|
|
142
|
+
ws.addRow({
|
|
143
|
+
author,
|
|
144
|
+
period: p,
|
|
145
|
+
changed: periodMap[p]
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
ws.autoFilter = {
|
|
151
|
+
from: { row: 1, column: 1 },
|
|
152
|
+
to: { row: 1, column: 3 }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
//-------------------------------------------
|
|
157
|
+
// 创建 3 个 Sheet
|
|
158
|
+
//-------------------------------------------
|
|
159
|
+
addStatSheet('Author Daily', stats.daily)
|
|
160
|
+
addStatSheet('Author Weekly', stats.weekly)
|
|
161
|
+
addStatSheet('Author Monthly', stats.monthly)
|
|
162
|
+
|
|
163
|
+
//-------------------------------------------
|
|
164
|
+
// 保存文件
|
|
165
|
+
//-------------------------------------------
|
|
166
|
+
await wb.xlsx.writeFile(file)
|
|
167
|
+
}
|
package/src/git.mjs
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { $ } from 'zx'
|
|
2
2
|
|
|
3
|
-
|
|
4
3
|
export async function getGitLogs(opts) {
|
|
5
4
|
const { author, email, since, until, limit, merges } = opts
|
|
6
5
|
|
|
7
|
-
// include subject and full body so we can extract Change-Id from commit message
|
|
8
6
|
const pretty = '%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%B%x1e'
|
|
9
|
-
|
|
10
7
|
const args = ['log', `--pretty=format:${pretty}`, '--date=iso']
|
|
11
8
|
|
|
12
9
|
if (author) args.push(`--author=${author}`)
|
|
@@ -16,15 +13,13 @@ export async function getGitLogs(opts) {
|
|
|
16
13
|
if (merges === false) args.push(`--no-merges`)
|
|
17
14
|
if (limit) args.push(`-n`, `${limit}`)
|
|
18
15
|
|
|
19
|
-
// 使用 spread 形式传参,ZX 才会正确处理
|
|
20
16
|
const { stdout } = await $`git ${args}`.quiet()
|
|
21
17
|
|
|
22
|
-
|
|
18
|
+
const commits = stdout
|
|
23
19
|
.split('\x1e')
|
|
24
20
|
.filter(Boolean)
|
|
25
21
|
.map((r) => {
|
|
26
22
|
const f = r.split('\x1f').map((s) => (s || '').trim())
|
|
27
|
-
|
|
28
23
|
const hash = f[0]
|
|
29
24
|
const authorName = f[1]
|
|
30
25
|
const emailAddr = f[2]
|
|
@@ -32,7 +27,6 @@ export async function getGitLogs(opts) {
|
|
|
32
27
|
const subject = f[4]
|
|
33
28
|
const body = f[5] || ''
|
|
34
29
|
|
|
35
|
-
// extract Change-Id from commit body (line like "Change-Id: Iabc123...")
|
|
36
30
|
const [, changeId] = body.match(/Change-Id:\s*(I[0-9a-fA-F]+)/) || []
|
|
37
31
|
|
|
38
32
|
return {
|
|
@@ -45,4 +39,36 @@ export async function getGitLogs(opts) {
|
|
|
45
39
|
changeId
|
|
46
40
|
}
|
|
47
41
|
})
|
|
42
|
+
|
|
43
|
+
// === 新增:为每个 commit 计算代码增量 ===
|
|
44
|
+
for (const c of commits) {
|
|
45
|
+
try {
|
|
46
|
+
const { stdout: diffOut } = await $`git show --numstat --format= ${c.hash}`.quiet()
|
|
47
|
+
// numstat 格式: "12 5 path/file.js"
|
|
48
|
+
const lines = diffOut
|
|
49
|
+
.split('\n')
|
|
50
|
+
.map((l) => l.trim())
|
|
51
|
+
.filter((l) => l && /^\d+\s+\d+/.test(l))
|
|
52
|
+
|
|
53
|
+
let added = 0
|
|
54
|
+
let deleted = 0
|
|
55
|
+
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
const [a, d] = line.split(/\s+/)
|
|
58
|
+
added += parseInt(a, 10) || 0
|
|
59
|
+
deleted += parseInt(d, 10) || 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
c.added = added
|
|
63
|
+
c.deleted = deleted
|
|
64
|
+
c.changed = added + deleted
|
|
65
|
+
} catch (err) {
|
|
66
|
+
// 避免阻塞,异常时改动量设置为0
|
|
67
|
+
c.added = 0
|
|
68
|
+
c.deleted = 0
|
|
69
|
+
c.changed = 0
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return commits
|
|
48
74
|
}
|
package/src/json.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function renderAuthorChangeStatsText(stats, opts = {}) {
|
|
2
|
+
const {
|
|
3
|
+
section = 'all', // 'daily' | 'weekly' | 'monthly' | 'all'
|
|
4
|
+
} = opts;
|
|
5
|
+
|
|
6
|
+
const pad = (s, n) =>
|
|
7
|
+
s.length >= n ? `${s.slice(0, n - 1)}…` : s + ' '.repeat(n - s.length);
|
|
8
|
+
|
|
9
|
+
const buildSection = (title, obj, unitLabel) => {
|
|
10
|
+
if (!obj || !Object.keys(obj).length) return '';
|
|
11
|
+
|
|
12
|
+
const lines = [];
|
|
13
|
+
|
|
14
|
+
const header =
|
|
15
|
+
`${pad('Author', 18)} | ${pad(unitLabel, 12)} | ${pad('Changed', 10)}`;
|
|
16
|
+
const line = '-'.repeat(header.length);
|
|
17
|
+
|
|
18
|
+
lines.push(`\n=== ${title} ===`);
|
|
19
|
+
lines.push(header);
|
|
20
|
+
lines.push(line);
|
|
21
|
+
|
|
22
|
+
for (const [author, periodMap] of Object.entries(obj)) {
|
|
23
|
+
const keys = Object.keys(periodMap).sort();
|
|
24
|
+
for (const key of keys) {
|
|
25
|
+
lines.push(
|
|
26
|
+
[
|
|
27
|
+
pad(author, 18),
|
|
28
|
+
pad(key, 12),
|
|
29
|
+
pad(String(periodMap[key]), 10),
|
|
30
|
+
].join(' | ')
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return lines.join('\n');
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let output = '';
|
|
39
|
+
|
|
40
|
+
if (section === 'all' || section === 'daily') {
|
|
41
|
+
output += buildSection('Daily Changed', stats.daily, 'Date');
|
|
42
|
+
}
|
|
43
|
+
if (section === 'all' || section === 'weekly') {
|
|
44
|
+
output += buildSection('Weekly Changed', stats.weekly, 'Week');
|
|
45
|
+
}
|
|
46
|
+
if (section === 'all' || section === 'monthly') {
|
|
47
|
+
output += buildSection('Monthly Changed', stats.monthly, 'Month');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return `${output.trim() }\n`;
|
|
51
|
+
}
|
package/src/stats.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import dayjs from 'dayjs'
|
|
2
|
+
import isoWeek from 'dayjs/plugin/isoWeek.js'
|
|
3
|
+
|
|
4
|
+
dayjs.extend(isoWeek)
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 自动统计每个作者的日/周/月 changed
|
|
8
|
+
*
|
|
9
|
+
* records: [{
|
|
10
|
+
* author: 'Tom',
|
|
11
|
+
* date: '2025-01-01T12:00:00Z',
|
|
12
|
+
* changed: 123
|
|
13
|
+
* }]
|
|
14
|
+
*/
|
|
15
|
+
export function buildAuthorChangeStats(records) {
|
|
16
|
+
const result = {
|
|
17
|
+
daily: {}, // { author: { 'YYYY-MM-DD': x } }
|
|
18
|
+
weekly: {}, // { author: { 'YYYY-WW': x } }
|
|
19
|
+
monthly: {} // { author: { 'YYYY-MM': x } }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const r of records) {
|
|
23
|
+
const author = r.author || 'Unknown'
|
|
24
|
+
const changed = Number(r.changed || 0)
|
|
25
|
+
|
|
26
|
+
const d = dayjs(r.date)
|
|
27
|
+
|
|
28
|
+
const dayKey = d.format('YYYY-MM-DD')
|
|
29
|
+
const weekKey = `${d.format('GGGG')}-W${d.isoWeek().toString().padStart(2, '0')}`
|
|
30
|
+
const monthKey = d.format('YYYY-MM')
|
|
31
|
+
|
|
32
|
+
if (!result.daily[author]) result.daily[author] = {}
|
|
33
|
+
if (!result.weekly[author]) result.weekly[author] = {}
|
|
34
|
+
if (!result.monthly[author]) result.monthly[author] = {}
|
|
35
|
+
|
|
36
|
+
result.daily[author][dayKey] = (result.daily[author][dayKey] || 0) + changed
|
|
37
|
+
result.weekly[author][weekKey] =
|
|
38
|
+
(result.weekly[author][weekKey] || 0) + changed
|
|
39
|
+
result.monthly[author][monthKey] =
|
|
40
|
+
(result.monthly[author][monthKey] || 0) + changed
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return result
|
|
44
|
+
}
|
package/src/text.mjs
CHANGED
|
@@ -1,56 +1,51 @@
|
|
|
1
|
+
import { renderAuthorChangeStatsText } from './stats-text.mjs'
|
|
2
|
+
import { buildAuthorChangeStats } from './stats.mjs'
|
|
3
|
+
|
|
1
4
|
export function renderText(records, groups = null, opts = {}) {
|
|
2
|
-
const { showGerrit = false } = opts
|
|
5
|
+
const { showGerrit = false } = opts
|
|
3
6
|
const pad = (s, n) =>
|
|
4
|
-
s.length >= n ? `${s.slice(0, n - 1)
|
|
7
|
+
s.length >= n ? `${s.slice(0, n - 1)}…` : s + ' '.repeat(n - s.length)
|
|
8
|
+
|
|
9
|
+
const baseHeader = `${pad('Hash', 10)} | ${pad('Author', 18)} | ${pad(
|
|
10
|
+
'Date',
|
|
11
|
+
20
|
|
12
|
+
)} | ${pad('Message', 40)} | ${pad('Changed', 8)}` // ★ 新增 changed 列
|
|
5
13
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
} | ${
|
|
9
|
-
pad('Author', 18)
|
|
10
|
-
} | ${
|
|
11
|
-
pad('Date', 20)
|
|
12
|
-
} | ${
|
|
13
|
-
pad('Message', 60)}`;
|
|
14
|
+
const gerritHeader = showGerrit ? ` | ${pad('Gerrit', 50)}` : ''
|
|
15
|
+
const header = baseHeader + gerritHeader
|
|
14
16
|
|
|
15
|
-
const
|
|
16
|
-
const header = baseHeader + gerritHeader;
|
|
17
|
+
const line = '-'.repeat(header.length)
|
|
17
18
|
|
|
18
|
-
const
|
|
19
|
+
const rows = []
|
|
19
20
|
|
|
20
|
-
const
|
|
21
|
+
const buildRow = (r) => {
|
|
22
|
+
return (
|
|
23
|
+
[
|
|
24
|
+
pad(r.hash.slice(0, 8), 10),
|
|
25
|
+
pad(r.author, 18),
|
|
26
|
+
pad(r.date.replace(/ .+/, ''), 20),
|
|
27
|
+
pad(r.message, 40),
|
|
28
|
+
pad(String(r.changed ?? 0), 8) // ★ 输出 changed 数量
|
|
29
|
+
].join(' | ') + (showGerrit ? ` | ${pad(r.gerrit || '', 50)}` : '')
|
|
30
|
+
)
|
|
31
|
+
}
|
|
21
32
|
|
|
22
33
|
if (groups) {
|
|
23
34
|
for (const [g, list] of Object.entries(groups)) {
|
|
24
|
-
rows.push(`\n=== ${g} ===\n`)
|
|
25
|
-
list.forEach(r =>
|
|
26
|
-
rows.push(
|
|
27
|
-
(
|
|
28
|
-
[
|
|
29
|
-
pad(r.hash.slice(0, 8), 10),
|
|
30
|
-
pad(r.author, 18),
|
|
31
|
-
pad(r.date.replace(/ .+/, ''), 20),
|
|
32
|
-
pad(r.message, 60)
|
|
33
|
-
].join(' | ') +
|
|
34
|
-
(showGerrit ? ` | ${ pad(r.gerrit || '', 50)}` : '')
|
|
35
|
-
)
|
|
36
|
-
);
|
|
37
|
-
});
|
|
35
|
+
rows.push(`\n=== ${g} ===\n`)
|
|
36
|
+
list.forEach((r) => rows.push(buildRow(r)))
|
|
38
37
|
}
|
|
39
38
|
} else {
|
|
40
|
-
records.forEach(r =>
|
|
41
|
-
rows.push(
|
|
42
|
-
(
|
|
43
|
-
[
|
|
44
|
-
pad(r.hash.slice(0, 8), 10),
|
|
45
|
-
pad(r.author, 18),
|
|
46
|
-
pad(r.date.replace(/ .+/, ''), 20),
|
|
47
|
-
pad(r.message, 60)
|
|
48
|
-
].join(' | ') +
|
|
49
|
-
(showGerrit ? ` | ${ pad(r.gerrit || '', 50)}` : '')
|
|
50
|
-
)
|
|
51
|
-
);
|
|
52
|
-
});
|
|
39
|
+
records.forEach((r) => rows.push(buildRow(r)))
|
|
53
40
|
}
|
|
54
41
|
|
|
55
|
-
return [header, line, ...rows].join('\n')
|
|
42
|
+
return [header, line, ...rows].join('\n')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renderChangedLinesText(records, opts = {}) {
|
|
46
|
+
const stats = buildAuthorChangeStats(records)
|
|
47
|
+
|
|
48
|
+
const result = renderAuthorChangeStatsText(stats, opts)
|
|
49
|
+
|
|
50
|
+
return result
|
|
56
51
|
}
|
package/web/app.js
CHANGED
|
@@ -9,14 +9,16 @@ async function loadData() {
|
|
|
9
9
|
weeklyModule,
|
|
10
10
|
monthlyModule,
|
|
11
11
|
latestByDayModule,
|
|
12
|
-
configModule
|
|
12
|
+
configModule,
|
|
13
|
+
authorChangesModule
|
|
13
14
|
] = await Promise.all([
|
|
14
15
|
import('/data/commits.mjs'),
|
|
15
16
|
import('/data/overtime-stats.mjs'),
|
|
16
17
|
import('/data/overtime-weekly.mjs'),
|
|
17
18
|
import('/data/overtime-monthly.mjs').catch(() => ({ default: [] })),
|
|
18
19
|
import('/data/overtime-latest-by-day.mjs').catch(() => ({ default: [] })),
|
|
19
|
-
import('/data/config.mjs').catch(() => ({ default: {} }))
|
|
20
|
+
import('/data/config.mjs').catch(() => ({ default: {} })),
|
|
21
|
+
import('/data/author-changes.mjs').catch(() => ({ default: {} }))
|
|
20
22
|
])
|
|
21
23
|
const commits = commitsModule.default || []
|
|
22
24
|
const stats = statsModule.default || {}
|
|
@@ -24,7 +26,8 @@ async function loadData() {
|
|
|
24
26
|
const monthly = monthlyModule.default || []
|
|
25
27
|
const latestByDay = latestByDayModule.default || []
|
|
26
28
|
const config = configModule.default || {}
|
|
27
|
-
|
|
29
|
+
const authorChanges = authorChangesModule.default || {}
|
|
30
|
+
return { commits, stats, weekly, monthly, latestByDay, config, authorChanges }
|
|
28
31
|
} catch (err) {
|
|
29
32
|
console.error('Load data failed', err)
|
|
30
33
|
return { commits: [], stats: {}, weekly: [], monthly: [], latestByDay: [] }
|
|
@@ -184,14 +187,24 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
184
187
|
data: [
|
|
185
188
|
{
|
|
186
189
|
name: '上班开始',
|
|
190
|
+
nameValue: String(stats.startHour).padStart(2, '0'),
|
|
187
191
|
xAxis: String(stats.startHour).padStart(2, '0')
|
|
188
192
|
},
|
|
189
|
-
{
|
|
193
|
+
{
|
|
194
|
+
name: '下班时间',
|
|
195
|
+
nameValue: String(stats.endHour).padStart(2, '0'),
|
|
196
|
+
xAxis: String(stats.endHour).padStart(2, '0')
|
|
197
|
+
},
|
|
190
198
|
{
|
|
191
199
|
name: '午休开始',
|
|
200
|
+
nameValue: String(stats.lunchStart).padStart(2, '0'),
|
|
192
201
|
xAxis: String(stats.lunchStart).padStart(2, '0')
|
|
193
202
|
},
|
|
194
|
-
{
|
|
203
|
+
{
|
|
204
|
+
name: '午休结束',
|
|
205
|
+
nameValue: String(stats.lunchEnd).padStart(2, '0'),
|
|
206
|
+
xAxis: String(stats.lunchEnd).padStart(2, '0')
|
|
207
|
+
}
|
|
195
208
|
]
|
|
196
209
|
}
|
|
197
210
|
}
|
|
@@ -201,7 +214,14 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
201
214
|
// 点击事件(点击某小时 → 打开侧栏)
|
|
202
215
|
if (typeof onHourClick === 'function') {
|
|
203
216
|
chart.on('click', (p) => {
|
|
204
|
-
|
|
217
|
+
let hour = Number(p.name)
|
|
218
|
+
if(p.componentType === 'markLine') {
|
|
219
|
+
hour = Number(p.data.xAxis)
|
|
220
|
+
}
|
|
221
|
+
// FIXME: remove debug log before production
|
|
222
|
+
console.log('❌', 'hour', hour, p)
|
|
223
|
+
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
224
|
+
if (Object.is(hour, NaN)) return
|
|
205
225
|
onHourClick(hour, commits[hour])
|
|
206
226
|
})
|
|
207
227
|
}
|
|
@@ -214,6 +234,7 @@ function showSideBarForHour(hour, commitsOrCount) {
|
|
|
214
234
|
// 支持传入 number(仅次数)或 array(详细 commit 列表)
|
|
215
235
|
// 统一复用通用详情侧栏 DOM
|
|
216
236
|
const sidebar = document.getElementById('dayDetailSidebar')
|
|
237
|
+
const backdrop = document.getElementById('sidebarBackdrop')
|
|
217
238
|
const titleEl = document.getElementById('sidebarTitle')
|
|
218
239
|
const contentEl = document.getElementById('sidebarContent')
|
|
219
240
|
|
|
@@ -235,19 +256,22 @@ function showSideBarForHour(hour, commitsOrCount) {
|
|
|
235
256
|
} else if (Array.isArray(commitsOrCount)) {
|
|
236
257
|
// commits 列表:展示作者/时间/消息(最多前 50 条,避免性能问题)
|
|
237
258
|
const commits = commitsOrCount.slice(0, 50)
|
|
238
|
-
contentEl.innerHTML = commits
|
|
259
|
+
contentEl.innerHTML = `<div class="sidebar-list">${commits
|
|
239
260
|
.map((c) => {
|
|
240
261
|
const author = c.author ?? c.name ?? 'unknown'
|
|
241
262
|
const time = c.date ?? c.time ?? ''
|
|
242
263
|
const msg = (c.message ?? c.msg ?? c.body ?? '').replace(/\n/g, ' ')
|
|
243
264
|
return `
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
265
|
+
<div class="sidebar-item">
|
|
266
|
+
<div class="sidebar-item-header">
|
|
267
|
+
<span class="author">👤 ${escapeHtml(author)}</span>
|
|
268
|
+
<span class="time">🕒 ${escapeHtml(time)}</span>
|
|
269
|
+
</div>
|
|
270
|
+
<div class="sidebar-item-message">${escapeHtml(msg)}</div>
|
|
271
|
+
</div>
|
|
272
|
+
`
|
|
249
273
|
})
|
|
250
|
-
.join('')
|
|
274
|
+
.join('')}</div>`
|
|
251
275
|
|
|
252
276
|
if (commitsOrCount.length > 50) {
|
|
253
277
|
const more = commitsOrCount.length - 50
|
|
@@ -257,8 +281,9 @@ function showSideBarForHour(hour, commitsOrCount) {
|
|
|
257
281
|
contentEl.innerHTML = `<div style="font-size:14px;">无可展示数据</div>`
|
|
258
282
|
}
|
|
259
283
|
|
|
260
|
-
// 打开侧栏
|
|
284
|
+
// 打开侧栏 + 遮罩
|
|
261
285
|
sidebar.classList.add('show')
|
|
286
|
+
if (backdrop) backdrop.classList.add('show')
|
|
262
287
|
}
|
|
263
288
|
|
|
264
289
|
// 简单的 HTML 转义,防止 XSS 与布局断裂
|
|
@@ -410,6 +435,7 @@ function drawDailyTrend(commits, onDayClick) {
|
|
|
410
435
|
function showSideBarForWeek(period, weeklyItem, commits = []) {
|
|
411
436
|
// 统一复用通用详情侧栏 DOM
|
|
412
437
|
const sidebar = document.getElementById('dayDetailSidebar')
|
|
438
|
+
const backdrop = document.getElementById('sidebarBackdrop')
|
|
413
439
|
const titleEl = document.getElementById('sidebarTitle')
|
|
414
440
|
const contentEl = document.getElementById('sidebarContent')
|
|
415
441
|
|
|
@@ -426,22 +452,27 @@ function showSideBarForWeek(period, weeklyItem, commits = []) {
|
|
|
426
452
|
if (!commits.length) {
|
|
427
453
|
html += `<div style="padding:10px;color:#777;">该周无提交记录</div>`
|
|
428
454
|
} else {
|
|
429
|
-
html += commits
|
|
455
|
+
html += `<div class="sidebar-list">${commits
|
|
430
456
|
.map((c) => {
|
|
457
|
+
const author = escapeHtml(c.author || 'unknown')
|
|
458
|
+
const time = escapeHtml(c.date || '')
|
|
459
|
+
const msg = escapeHtml((c.message || '').replace(/\n/g, ' '))
|
|
431
460
|
return `
|
|
432
|
-
<div class="
|
|
433
|
-
<div class="
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
461
|
+
<div class="sidebar-item">
|
|
462
|
+
<div class="sidebar-item-header">
|
|
463
|
+
<span class="author">👤 ${author}</span>
|
|
464
|
+
<span class="time">🕒 ${time}</span>
|
|
465
|
+
</div>
|
|
466
|
+
<div class="sidebar-item-message">${msg}</div>
|
|
437
467
|
</div>
|
|
438
468
|
`
|
|
439
469
|
})
|
|
440
|
-
.join('')
|
|
470
|
+
.join('')}</div>`
|
|
441
471
|
}
|
|
442
472
|
|
|
443
473
|
contentEl.innerHTML = html
|
|
444
474
|
sidebar.classList.add('show')
|
|
475
|
+
if (backdrop) backdrop.classList.add('show')
|
|
445
476
|
}
|
|
446
477
|
|
|
447
478
|
function drawWeeklyTrend(weekly, commits, onWeekClick) {
|
|
@@ -543,7 +574,6 @@ function drawWeeklyTrend(weekly, commits, onWeekClick) {
|
|
|
543
574
|
const idx = p.dataIndex
|
|
544
575
|
const w = weekly[idx]
|
|
545
576
|
|
|
546
|
-
|
|
547
577
|
const start = new Date(w.range.start)
|
|
548
578
|
const end = new Date(w.range.end)
|
|
549
579
|
end.setHours(23, 59, 59, 999) // 包含当天
|
|
@@ -828,9 +858,7 @@ function drawDailySeverity(latestByDay, commits, onDayClick) {
|
|
|
828
858
|
|
|
829
859
|
// 若某天 latestHourNormalized 为空,表示「没有下班后到次日上班前的提交」,
|
|
830
860
|
// 这里按 0 小时加班处理,保证折线连续。
|
|
831
|
-
const sev = raw.map((v) =>
|
|
832
|
-
v == null ? 0 : Math.max(0, Number(v) - endH)
|
|
833
|
-
)
|
|
861
|
+
const sev = raw.map((v) => (v == null ? 0 : Math.max(0, Number(v) - endH)))
|
|
834
862
|
|
|
835
863
|
const el = document.getElementById('dailySeverityChart')
|
|
836
864
|
// eslint-disable-next-line no-undef
|
|
@@ -1101,6 +1129,7 @@ function drawDailyTrendSeverity(commits, weekly, onDayClick) {
|
|
|
1101
1129
|
|
|
1102
1130
|
function showDayDetailSidebar(date, count, commits) {
|
|
1103
1131
|
const sidebar = document.getElementById('dayDetailSidebar')
|
|
1132
|
+
const backdrop = document.getElementById('sidebarBackdrop')
|
|
1104
1133
|
const title = document.getElementById('sidebarTitle')
|
|
1105
1134
|
const content = document.getElementById('sidebarContent')
|
|
1106
1135
|
|
|
@@ -1110,17 +1139,19 @@ function showDayDetailSidebar(date, count, commits) {
|
|
|
1110
1139
|
content.innerHTML = commits
|
|
1111
1140
|
.map(
|
|
1112
1141
|
(c) => `
|
|
1113
|
-
<div
|
|
1114
|
-
<div
|
|
1115
|
-
|
|
1116
|
-
|
|
1142
|
+
<div class="sidebar-item">
|
|
1143
|
+
<div class="sidebar-item-header">
|
|
1144
|
+
<span class="author">👤 ${escapeHtml(c.author || 'unknown')}</span>
|
|
1145
|
+
<span class="time">🕒 ${escapeHtml(c.time || c.date || '')}</span>
|
|
1146
|
+
</div>
|
|
1147
|
+
<div class="sidebar-item-message">${escapeHtml(c.msg || c.message || '')}</div>
|
|
1117
1148
|
</div>
|
|
1118
|
-
<hr/>
|
|
1119
1149
|
`
|
|
1120
1150
|
)
|
|
1121
1151
|
.join('')
|
|
1122
1152
|
|
|
1123
1153
|
sidebar.classList.add('show')
|
|
1154
|
+
if (backdrop) backdrop.classList.add('show')
|
|
1124
1155
|
}
|
|
1125
1156
|
|
|
1126
1157
|
function renderKpi(stats) {
|
|
@@ -1132,13 +1163,13 @@ function renderKpi(stats) {
|
|
|
1132
1163
|
// 使用 cutoff + 上下班时间,重新在全部 commits 中计算「加班最晚一次提交」
|
|
1133
1164
|
const cutoff = window.__overnightCutoff ?? 6
|
|
1134
1165
|
const startHour =
|
|
1135
|
-
|
|
1166
|
+
typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
1136
1167
|
? stats.startHour
|
|
1137
|
-
: 9
|
|
1168
|
+
: 9
|
|
1138
1169
|
const endHour =
|
|
1139
|
-
|
|
1170
|
+
typeof stats.endHour === 'number' && stats.endHour >= 0
|
|
1140
1171
|
? stats.endHour
|
|
1141
|
-
: window.__overtimeEndHour ?? 18)
|
|
1172
|
+
: (window.__overtimeEndHour ?? 18)
|
|
1142
1173
|
|
|
1143
1174
|
let latestOut = null
|
|
1144
1175
|
let latestOutHour = null
|
|
@@ -1198,50 +1229,6 @@ function groupCommitsByHour(commits) {
|
|
|
1198
1229
|
return byHour
|
|
1199
1230
|
}
|
|
1200
1231
|
|
|
1201
|
-
async function main() {
|
|
1202
|
-
const { commits, stats, weekly, monthly, latestByDay, config } =
|
|
1203
|
-
await loadData()
|
|
1204
|
-
commitsAll = commits
|
|
1205
|
-
filtered = commitsAll.slice()
|
|
1206
|
-
window.__overtimeEndHour =
|
|
1207
|
-
stats && typeof stats.endHour === 'number'
|
|
1208
|
-
? stats.endHour
|
|
1209
|
-
: (config.endHour ?? 18)
|
|
1210
|
-
window.__overnightCutoff =
|
|
1211
|
-
typeof config.overnightCutoff === 'number' ? config.overnightCutoff : 6
|
|
1212
|
-
initTableControls()
|
|
1213
|
-
updatePager()
|
|
1214
|
-
renderCommitsTablePage()
|
|
1215
|
-
|
|
1216
|
-
drawHourlyOvertime(stats, (hour, count) => {
|
|
1217
|
-
// 使用举例
|
|
1218
|
-
const hourCommitsDetail = groupCommitsByHour(commits)
|
|
1219
|
-
// 将 commit 列表传给侧栏(若没有详情,则传空数组)
|
|
1220
|
-
showSideBarForHour(hour, hourCommitsDetail[hour] || [])
|
|
1221
|
-
})
|
|
1222
|
-
drawOutsideVsInside(stats)
|
|
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
|
-
|
|
1239
|
-
const daily = drawDailyTrendSeverity(commits, weekly, showDayDetailSidebar)
|
|
1240
|
-
|
|
1241
|
-
console.log('最累的一天:', daily.analysis.mostTiredDay)
|
|
1242
|
-
computeAndRenderLatestOvertime(latestByDay)
|
|
1243
|
-
renderKpi(stats)
|
|
1244
|
-
}
|
|
1245
1232
|
|
|
1246
1233
|
// 基于 latestByDay + cutoff/endHour 统计「最晚加班的一天 / 一周 / 一月」
|
|
1247
1234
|
function computeAndRenderLatestOvertime(latestByDay) {
|
|
@@ -1349,8 +1336,119 @@ function computeAndRenderLatestOvertime(latestByDay) {
|
|
|
1349
1336
|
}
|
|
1350
1337
|
}
|
|
1351
1338
|
|
|
1352
|
-
|
|
1339
|
+
function buildDataset(stats, type) {
|
|
1340
|
+
const dataMap = stats[type]; // { author: { period: changed } }
|
|
1341
|
+
|
|
1342
|
+
const authors = Object.keys(dataMap);
|
|
1343
|
+
const allPeriods = Array.from(new Set(
|
|
1344
|
+
authors.flatMap(a => Object.keys(dataMap[a]))
|
|
1345
|
+
)).sort();
|
|
1346
|
+
|
|
1347
|
+
const series = authors.map(a => ({
|
|
1348
|
+
name: a,
|
|
1349
|
+
type: 'line',
|
|
1350
|
+
smooth: true,
|
|
1351
|
+
data: allPeriods.map(p => dataMap[a][p] || 0)
|
|
1352
|
+
}));
|
|
1353
|
+
|
|
1354
|
+
return { authors, allPeriods, series };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
const drawChangeTrends = (stats, type = 'daily') => {
|
|
1358
|
+
const el = document.getElementById("chartAuthorChanges");
|
|
1359
|
+
if (!el) return null;
|
|
1360
|
+
// FIXME: remove debug log before production
|
|
1361
|
+
console.log('❌', 'el', el);
|
|
1362
|
+
const chart = echarts.init(el);
|
|
1363
|
+
|
|
1364
|
+
function render(type) {
|
|
1365
|
+
const { authors, allPeriods, series } = buildDataset(stats, type);
|
|
1366
|
+
|
|
1367
|
+
chart.setOption({
|
|
1368
|
+
tooltip: { trigger: 'axis' },
|
|
1369
|
+
legend: { data: authors },
|
|
1370
|
+
xAxis: { type: 'category', data: allPeriods },
|
|
1371
|
+
yAxis: { type: 'value' },
|
|
1372
|
+
series
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// 初次渲染:日
|
|
1377
|
+
render('daily');
|
|
1378
|
+
|
|
1379
|
+
// tabs 切换
|
|
1380
|
+
document.querySelectorAll('#tabs button').forEach(btn => {
|
|
1381
|
+
btn.onclick = () => {
|
|
1382
|
+
document.querySelectorAll('#tabs button').forEach(b => b.classList.remove('active'));
|
|
1383
|
+
btn.classList.add('active');
|
|
1384
|
+
|
|
1385
|
+
render(btn.dataset.type);
|
|
1386
|
+
};
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
return chart;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
async function main() {
|
|
1393
|
+
const { commits, stats, weekly, monthly, latestByDay, config,authorChanges } =
|
|
1394
|
+
await loadData()
|
|
1395
|
+
commitsAll = commits
|
|
1396
|
+
filtered = commitsAll.slice()
|
|
1397
|
+
window.__overtimeEndHour =
|
|
1398
|
+
stats && typeof stats.endHour === 'number'
|
|
1399
|
+
? stats.endHour
|
|
1400
|
+
: (config.endHour ?? 18)
|
|
1401
|
+
window.__overnightCutoff =
|
|
1402
|
+
typeof config.overnightCutoff === 'number' ? config.overnightCutoff : 6
|
|
1403
|
+
initTableControls()
|
|
1404
|
+
updatePager()
|
|
1405
|
+
renderCommitsTablePage()
|
|
1406
|
+
|
|
1407
|
+
drawHourlyOvertime(stats, (hour, count) => {
|
|
1408
|
+
// 使用举例
|
|
1409
|
+
const hourCommitsDetail = groupCommitsByHour(commits)
|
|
1410
|
+
// 将 commit 列表传给侧栏(若没有详情,则传空数组)
|
|
1411
|
+
showSideBarForHour(hour, hourCommitsDetail[hour] || [])
|
|
1412
|
+
})
|
|
1413
|
+
drawOutsideVsInside(stats)
|
|
1414
|
+
|
|
1415
|
+
// 按日提交趋势:点击某天打开抽屉,显示当日所有 commits
|
|
1416
|
+
drawDailyTrend(commits, showDayDetailSidebar)
|
|
1417
|
+
|
|
1418
|
+
// 周趋势:保持原有点击行为(显示该周详情)
|
|
1419
|
+
drawWeeklyTrend(weekly, commits, showSideBarForWeek)
|
|
1420
|
+
|
|
1421
|
+
// 月趋势(加班占比):点击某个月打开抽屉,显示该月所有 commits
|
|
1422
|
+
drawMonthlyTrend(monthly, commits, showDayDetailSidebar)
|
|
1423
|
+
|
|
1424
|
+
// 每日最晚提交时间(小时):点击某天打开抽屉,显示当日所有 commits
|
|
1425
|
+
drawLatestHourDaily(latestByDay, commits, showDayDetailSidebar)
|
|
1426
|
+
|
|
1427
|
+
// 每日超过下班的小时数:点击某天打开抽屉,显示当日所有 commits
|
|
1428
|
+
drawDailySeverity(latestByDay, commits, showDayDetailSidebar)
|
|
1429
|
+
|
|
1430
|
+
const daily = drawDailyTrendSeverity(commits, weekly, showDayDetailSidebar)
|
|
1431
|
+
|
|
1432
|
+
console.log('最累的一天:', daily.analysis.mostTiredDay)
|
|
1433
|
+
|
|
1434
|
+
drawChangeTrends(authorChanges, 'daily');
|
|
1435
|
+
computeAndRenderLatestOvertime(latestByDay)
|
|
1436
|
+
renderKpi(stats)
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
// 抽屉关闭交互(按钮 + 点击遮罩)
|
|
1353
1441
|
document.getElementById('sidebarClose').onclick = () => {
|
|
1354
1442
|
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
1443
|
+
const backdrop = document.getElementById('sidebarBackdrop')
|
|
1444
|
+
if (backdrop) backdrop.classList.remove('show')
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const sidebarBackdropEl = document.getElementById('sidebarBackdrop')
|
|
1448
|
+
if (sidebarBackdropEl) {
|
|
1449
|
+
sidebarBackdropEl.addEventListener('click', () => {
|
|
1450
|
+
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
1451
|
+
sidebarBackdropEl.classList.remove('show')
|
|
1452
|
+
})
|
|
1355
1453
|
}
|
|
1356
1454
|
main()
|
package/web/index.html
CHANGED
|
@@ -61,6 +61,17 @@
|
|
|
61
61
|
<div id="latestOvertimeMonth"></div>
|
|
62
62
|
</section>
|
|
63
63
|
|
|
64
|
+
<div class="chart-card">
|
|
65
|
+
<h2>开发者 Changed 工作量趋势</h2>
|
|
66
|
+
|
|
67
|
+
<div id="tabs">
|
|
68
|
+
<button data-type="daily" class="active">按日</button>
|
|
69
|
+
<button data-type="weekly">按周</button>
|
|
70
|
+
<button data-type="monthly">按月</button>
|
|
71
|
+
</div>
|
|
72
|
+
<div id="chartAuthorChanges" class="echart"></div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
64
75
|
<section class="table-card">
|
|
65
76
|
<h2>提交清单</h2>
|
|
66
77
|
<div id="tableControls">
|
|
@@ -102,6 +113,8 @@
|
|
|
102
113
|
>
|
|
103
114
|
</footer>
|
|
104
115
|
<!-- 通用:右侧滑出的详情侧栏(小时 / 天 / 周 复用同一个 DOM) -->
|
|
116
|
+
<!-- 背景遮罩,点击可关闭侧栏 -->
|
|
117
|
+
<div id="sidebarBackdrop" class="sidebar-backdrop"></div>
|
|
105
118
|
<div id="dayDetailSidebar" class="sidebar">
|
|
106
119
|
<div class="sidebar-header">
|
|
107
120
|
<span id="sidebarTitle"></span>
|
package/web/static/style.css
CHANGED
|
@@ -189,20 +189,40 @@ td {
|
|
|
189
189
|
margin-bottom: 8px;
|
|
190
190
|
color: #00a76f;
|
|
191
191
|
}
|
|
192
|
+
/* 抽屉 & 遮罩(参考 Material UI Drawer) -------------------- */
|
|
193
|
+
.sidebar-backdrop {
|
|
194
|
+
position: fixed;
|
|
195
|
+
inset: 0;
|
|
196
|
+
background: rgba(15, 23, 42, 0.38); /* 深色半透明遮罩 */
|
|
197
|
+
opacity: 0;
|
|
198
|
+
pointer-events: none;
|
|
199
|
+
transition: opacity 0.25s ease;
|
|
200
|
+
z-index: 1200;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.sidebar-backdrop.show {
|
|
204
|
+
opacity: 1;
|
|
205
|
+
pointer-events: auto;
|
|
206
|
+
}
|
|
207
|
+
|
|
192
208
|
.sidebar {
|
|
193
209
|
position: fixed;
|
|
194
210
|
top: 0;
|
|
195
|
-
right: -
|
|
196
|
-
width:
|
|
211
|
+
right: -460px;
|
|
212
|
+
width: min(460px, 100%);
|
|
197
213
|
height: 100%;
|
|
198
214
|
background: #fff;
|
|
199
|
-
box-shadow:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
215
|
+
box-shadow:
|
|
216
|
+
0 10px 15px rgba(0, 0, 0, 0.1),
|
|
217
|
+
0 4px 6px rgba(0, 0, 0, 0.08);
|
|
218
|
+
transition: right 0.3s ease;
|
|
219
|
+
z-index: 1300;
|
|
220
|
+
padding: 20px 20px 16px;
|
|
203
221
|
display: flex;
|
|
204
222
|
flex-direction: column;
|
|
205
223
|
box-sizing: border-box;
|
|
224
|
+
border-radius: 12px 0 0 12px;
|
|
225
|
+
border-left: 1px solid rgba(145, 158, 171, 0.24);
|
|
206
226
|
}
|
|
207
227
|
|
|
208
228
|
.sidebar.show {
|
|
@@ -213,35 +233,115 @@ td {
|
|
|
213
233
|
display: flex;
|
|
214
234
|
justify-content: space-between;
|
|
215
235
|
align-items: center;
|
|
216
|
-
font-size:
|
|
217
|
-
|
|
236
|
+
font-size: 16px;
|
|
237
|
+
font-weight: 500;
|
|
238
|
+
margin-bottom: 12px;
|
|
239
|
+
color: #111827;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.sidebar-header span {
|
|
243
|
+
display: inline-flex;
|
|
244
|
+
align-items: center;
|
|
245
|
+
gap: 6px;
|
|
218
246
|
}
|
|
219
247
|
|
|
220
248
|
.sidebar-header button {
|
|
221
|
-
|
|
249
|
+
width: 32px;
|
|
250
|
+
height: 32px;
|
|
251
|
+
display: inline-flex;
|
|
252
|
+
align-items: center;
|
|
253
|
+
justify-content: center;
|
|
254
|
+
background: rgba(148, 163, 184, 0.12);
|
|
222
255
|
border: none;
|
|
223
|
-
|
|
256
|
+
border-radius: 999px;
|
|
257
|
+
font-size: 18px;
|
|
224
258
|
cursor: pointer;
|
|
259
|
+
color: #4b5563;
|
|
260
|
+
transition:
|
|
261
|
+
background 0.2s ease,
|
|
262
|
+
color 0.2s ease,
|
|
263
|
+
box-shadow 0.2s ease;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.sidebar-header button:hover {
|
|
267
|
+
background: rgba(148, 163, 184, 0.22);
|
|
268
|
+
color: #111827;
|
|
269
|
+
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18);
|
|
225
270
|
}
|
|
226
271
|
|
|
227
272
|
.sidebar-content {
|
|
228
273
|
overflow-y: auto;
|
|
229
|
-
font-size:
|
|
274
|
+
font-size: 13px;
|
|
275
|
+
line-height: 1.6;
|
|
276
|
+
padding-right: 4px;
|
|
230
277
|
}
|
|
231
|
-
|
|
232
|
-
.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
278
|
+
|
|
279
|
+
.sidebar-content::-webkit-scrollbar {
|
|
280
|
+
width: 6px;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.sidebar-content::-webkit-scrollbar-thumb {
|
|
284
|
+
background: rgba(148, 163, 184, 0.6);
|
|
285
|
+
border-radius: 999px;
|
|
238
286
|
}
|
|
239
|
-
|
|
240
|
-
|
|
287
|
+
|
|
288
|
+
.sidebar-content::-webkit-scrollbar-track {
|
|
289
|
+
background: transparent;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* 抽屉中的提交列表(参考 MUI List / ListItem) ------------- */
|
|
293
|
+
.sidebar-list {
|
|
294
|
+
display: flex;
|
|
295
|
+
flex-direction: column;
|
|
296
|
+
gap: 8px;
|
|
297
|
+
padding: 4px 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.sidebar-item,
|
|
301
|
+
.hour-commit,
|
|
302
|
+
.week-commit {
|
|
303
|
+
padding: 10px 12px;
|
|
304
|
+
border-radius: 8px;
|
|
305
|
+
background: #f9fafb;
|
|
306
|
+
border: 1px solid rgba(148, 163, 184, 0.25);
|
|
307
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
308
|
+
transition:
|
|
309
|
+
background 0.18s ease,
|
|
310
|
+
box-shadow 0.18s ease,
|
|
311
|
+
transform 0.1s ease;
|
|
312
|
+
margin: 8px 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.sidebar-item:hover,
|
|
316
|
+
.hour-commit:hover,
|
|
317
|
+
.week-commit:hover {
|
|
318
|
+
background: #f3f4ff;
|
|
319
|
+
box-shadow: 0 3px 8px rgba(15, 23, 42, 0.12);
|
|
320
|
+
transform: translateY(-1px);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.sidebar-item-header {
|
|
324
|
+
display: flex;
|
|
325
|
+
justify-content: space-between;
|
|
326
|
+
align-items: center;
|
|
241
327
|
font-size: 12px;
|
|
242
|
-
|
|
328
|
+
color: #64748b;
|
|
329
|
+
margin-bottom: 4px;
|
|
243
330
|
}
|
|
244
|
-
|
|
331
|
+
|
|
332
|
+
.sidebar-item-header .author {
|
|
333
|
+
font-weight: 600;
|
|
334
|
+
color: #111827;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.sidebar-item-header .time {
|
|
338
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
|
339
|
+
'Liberation Mono', 'Courier New', monospace;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.sidebar-item-message {
|
|
343
|
+
font-size: 13px;
|
|
344
|
+
color: #111827;
|
|
245
345
|
font-weight: 500;
|
|
246
346
|
word-break: break-word;
|
|
247
347
|
}
|