wukong-gitlog-cli 0.0.10 → 0.0.12
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 +22 -0
- package/README.md +17 -0
- package/doc/help.md +19 -0
- package/package.json +3 -1
- package/src/cli.mjs +116 -107
- package/src/overtime.mjs +28 -19
- package/src/server.mjs +85 -0
- package/web/app.js +104 -0
- package/web/index.html +49 -0
- package/web/style.css +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
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
|
+
### [0.0.12](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v0.0.11...v0.0.12) (2025-11-28)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* 🎸 add web server ([b2e3d80](https://github.com/tomatobybike/wukong-gitlog-cli/commit/b2e3d80d1a998a07b289a2300d2ec53cd0d586ad))
|
|
11
|
+
* 🎸 create web ([3e2da00](https://github.com/tomatobybike/wukong-gitlog-cli/commit/3e2da00222e74199eea6e324e15008e2bb2be127))
|
|
12
|
+
* 🎸 fix eslint ([2666eed](https://github.com/tomatobybike/wukong-gitlog-cli/commit/2666eedd2b41dc1aa88cb9bb3e12a45ec0390f0b))
|
|
13
|
+
* 🎸 web ([6feac90](https://github.com/tomatobybike/wukong-gitlog-cli/commit/6feac90f85a9142bb0e68b98250f7755603086d9))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* 🐛 eslint error ([cb65012](https://github.com/tomatobybike/wukong-gitlog-cli/commit/cb65012589f543720d355505f3d8693fd573ee64))
|
|
19
|
+
|
|
20
|
+
### [0.0.11](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v0.0.10...v0.0.11) (2025-11-28)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Features
|
|
24
|
+
|
|
25
|
+
* 🎸 each hour commit ([029ff72](https://github.com/tomatobybike/wukong-gitlog-cli/commit/029ff72636a47bcb5e77b7fb5acf1ca31d9cf9f3))
|
|
26
|
+
|
|
5
27
|
### [0.0.10](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v0.0.9...v0.0.10) (2025-11-28)
|
|
6
28
|
|
|
7
29
|
|
package/README.md
CHANGED
|
@@ -108,6 +108,23 @@ If you'd like only per-period outputs and not the combined monthly/weekly summar
|
|
|
108
108
|
wukong-gitlog-cli ./src/cli.mjs --overtime --limit 200 --format text --out commits.txt --per-period-formats csv,tab,xlsx --per-period-only
|
|
109
109
|
```
|
|
110
110
|
|
|
111
|
+
### Serve a local dashboard
|
|
112
|
+
You can start a small static web dashboard to visualize commit statistics and charts. It will export raw commits and analyzed stats into `output/data/` as `commits.mjs` and `overtime-stats.mjs`, and start a local web server serving `web/` and `output/data/`:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
# Start the server on the default port (3000)
|
|
116
|
+
wukong-gitlog-cli --serve --overtime --limit 200 --out commits.txt
|
|
117
|
+
|
|
118
|
+
# Start server only (use existing output/data)
|
|
119
|
+
wukong-gitlog-cli --serve-only
|
|
120
|
+
|
|
121
|
+
# Custom port
|
|
122
|
+
wukong-gitlog-cli --serve --port 8080 --overtime --limit 200 --out commits.txt
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Open `http://localhost:3000` to view the dashboard.
|
|
126
|
+
|
|
127
|
+
|
|
111
128
|
|
|
112
129
|
---
|
|
113
130
|
|
package/doc/help.md
CHANGED
|
@@ -192,3 +192,22 @@ Example JSON output including `changeId`/`gerrit` when `--gerrit` uses `{{change
|
|
|
192
192
|
```
|
|
193
193
|
|
|
194
194
|
---
|
|
195
|
+
|
|
196
|
+
如何使用(示例):
|
|
197
|
+
|
|
198
|
+
生成数据并启动本地仪表盘(默认端口 3000):
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
node ./src/cli.mjs --serve --overtime --limit 200 --out commits.txt
|
|
202
|
+
# 或
|
|
203
|
+
npm run cli:serve
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
指定端口:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
node ./src/cli.mjs --serve --port 8080 --overtime --limit 200 --out commits.txt
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
仪表盘地址:打开 http://localhost:3000 (或自定义端口)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wukong-gitlog-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"description": "Advanced Git commit log exporter with Excel/JSON/TXT output, grouping, stats and CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"git",
|
|
@@ -58,6 +58,8 @@
|
|
|
58
58
|
"cli:overtime-per-period-xlsx-sheets": "node ./src/cli.mjs --overtime --limit 200 --format text --out commits.txt --per-period-formats csv,tab,xlsx --per-period-excel-mode sheets",
|
|
59
59
|
"cli:overtime-per-period-xlsx-files": "node ./src/cli.mjs --overtime --limit 200 --format text --out commits.txt --per-period-formats xlsx --per-period-excel-mode files",
|
|
60
60
|
"cli:overtime-per-period-only": "node ./src/cli.mjs --overtime --limit 200 --format text --out commits.txt --per-period-formats csv,tab,xlsx --per-period-only",
|
|
61
|
+
"cli:serve": "node ./src/cli.mjs --serve --overtime --format text --out commits.txt",
|
|
62
|
+
"cli:serve-only": "node ./src/cli.mjs --serve-only",
|
|
61
63
|
"format": "prettier --write \"src/**/*.{js,mjs}\"",
|
|
62
64
|
"lint": "eslint src --ext .js,.mjs src",
|
|
63
65
|
"lint:fix": "eslint src --ext .js,.mjs --fix",
|
package/src/cli.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'path';
|
|
|
4
4
|
import { getGitLogs } from './git.mjs';
|
|
5
5
|
import { renderText } from './text.mjs';
|
|
6
6
|
import { analyzeOvertime, renderOvertimeText, renderOvertimeTab, renderOvertimeCsv } from './overtime.mjs';
|
|
7
|
+
import { startServer } from './server.mjs';
|
|
7
8
|
import { exportExcel, exportExcelPerPeriodSheets } from './excel.mjs';
|
|
8
9
|
import { groupRecords, writeJSON, writeTextFile, outputFilePath } from './utils/index.mjs';
|
|
9
10
|
|
|
@@ -37,17 +38,32 @@ program
|
|
|
37
38
|
.option('--per-period-formats <formats>', '每个周期单独输出的格式,逗号分隔:text,csv,tab,xlsx。默认为空(不输出 CSV/Tab/XLSX)', '')
|
|
38
39
|
.option('--per-period-excel-mode <mode>', 'per-period Excel 模式:sheets|files(默认:sheets)', 'sheets')
|
|
39
40
|
.option('--per-period-only', '仅输出 per-period(month/week)文件,不输出合并的 monthly/weekly 汇总文件')
|
|
41
|
+
.option('--serve', '启动本地 web 服务,查看提交统计(将在 output/data 下生成数据文件)')
|
|
42
|
+
.option('--port <n>', '本地 web 服务端口(默认 3000)', (v) => parseInt(v, 10), 3000)
|
|
43
|
+
.option('--serve-only', '仅启动 web 服务,不导出或分析数据(使用 output/data 中已有的数据)')
|
|
40
44
|
.parse();
|
|
41
45
|
|
|
42
46
|
const opts = program.opts();
|
|
47
|
+
// compute output directory root early (so serve-only can use it)
|
|
48
|
+
const outDir = opts.outParent
|
|
49
|
+
? path.resolve(process.cwd(), '..', 'output')
|
|
50
|
+
: opts.outDir || undefined;
|
|
43
51
|
|
|
44
52
|
(async () => {
|
|
53
|
+
// if serve-only is requested, start server and exit
|
|
54
|
+
if (opts.serveOnly) {
|
|
55
|
+
try {
|
|
56
|
+
await startServer(opts.port || 3000, outDir);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.warn('Start server failed:', err && err.message ? err.message : err);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
let records = await getGitLogs(opts);
|
|
46
65
|
|
|
47
66
|
// compute output directory root if user provided one or wants parent
|
|
48
|
-
const outDir = opts.outParent
|
|
49
|
-
? path.resolve(process.cwd(), '..', 'output')
|
|
50
|
-
: opts.outDir || undefined;
|
|
51
67
|
|
|
52
68
|
// --- Gerrit 地址处理(若提供) ---
|
|
53
69
|
if (opts.gerrit) {
|
|
@@ -66,7 +82,6 @@ const opts = program.opts();
|
|
|
66
82
|
headers.Authorization = `Bearer ${gerritAuth}`;
|
|
67
83
|
}
|
|
68
84
|
}
|
|
69
|
-
|
|
70
85
|
const fetchGerritJson = async (url) => {
|
|
71
86
|
try {
|
|
72
87
|
const res = await fetch(url, { headers });
|
|
@@ -78,7 +93,6 @@ const opts = program.opts();
|
|
|
78
93
|
return null;
|
|
79
94
|
}
|
|
80
95
|
};
|
|
81
|
-
|
|
82
96
|
const resolveChangeNumber = async (r) => {
|
|
83
97
|
// try changeId first
|
|
84
98
|
if (r.changeId) {
|
|
@@ -110,7 +124,6 @@ const opts = program.opts();
|
|
|
110
124
|
}
|
|
111
125
|
return null;
|
|
112
126
|
};
|
|
113
|
-
|
|
114
127
|
records = await Promise.all(
|
|
115
128
|
records.map(async (r) => {
|
|
116
129
|
const changeNumber = await resolveChangeNumber(r);
|
|
@@ -120,24 +133,22 @@ const opts = program.opts();
|
|
|
120
133
|
})
|
|
121
134
|
);
|
|
122
135
|
} else if (prefix.includes('{{changeNumber}}') && !gerritApi) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
console.warn('prefix contains {{changeNumber}} but no --gerrit-api provided — falling back to changeId/hash');
|
|
137
|
+
records = records.map((r) => ({ ...r, gerrit: prefix.replace('{{changeNumber}}', r.changeId || r.hash) }));
|
|
138
|
+
} else {
|
|
126
139
|
records = records.map((r) => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return { ...r, gerrit: gerritUrl };
|
|
140
|
+
let gerritUrl;
|
|
141
|
+
if (prefix.includes('{{changeId}}')) {
|
|
142
|
+
const changeId = r.changeId || r.hash;
|
|
143
|
+
gerritUrl = prefix.replace('{{changeId}}', changeId);
|
|
144
|
+
} else if (prefix.includes('{{hash}}')) {
|
|
145
|
+
gerritUrl = prefix.replace('{{hash}}', r.hash);
|
|
146
|
+
} else {
|
|
147
|
+
gerritUrl = prefix.endsWith('/') ? `${prefix}${r.hash}` : `${prefix}/${r.hash}`;
|
|
148
|
+
}
|
|
149
|
+
return { ...r, gerrit: gerritUrl };
|
|
139
150
|
});
|
|
140
|
-
|
|
151
|
+
}
|
|
141
152
|
}
|
|
142
153
|
|
|
143
154
|
// --- 分组 ---
|
|
@@ -178,7 +189,49 @@ const opts = program.opts();
|
|
|
178
189
|
console.log(chalk.green(`Overtime text 已导出: ${overtimeFile}`));
|
|
179
190
|
console.log(chalk.green(`Overtime table (tabs) 已导出: ${overtimeTabFile}`));
|
|
180
191
|
console.log(chalk.green(`Overtime CSV 已导出: ${overtimeCsvFile}`));
|
|
181
|
-
|
|
192
|
+
|
|
193
|
+
// If serve mode is enabled, write data modules and launch the web server
|
|
194
|
+
if (opts.serve) {
|
|
195
|
+
try {
|
|
196
|
+
const dataCommitsFile = outputFilePath('data/commits.mjs', outDir);
|
|
197
|
+
const commitsModule = `export default ${JSON.stringify(records, null, 2)};\n`;
|
|
198
|
+
writeTextFile(dataCommitsFile, commitsModule);
|
|
199
|
+
const dataStatsFile = outputFilePath('data/overtime-stats.mjs', outDir);
|
|
200
|
+
const statsModule = `export default ${JSON.stringify(stats, null, 2)};\n`;
|
|
201
|
+
writeTextFile(dataStatsFile, statsModule);
|
|
202
|
+
|
|
203
|
+
// 新增:每周趋势数据(用于前端图表)
|
|
204
|
+
const weekGroups = groupRecords(records, 'week');
|
|
205
|
+
const weekKeys = Object.keys(weekGroups).sort();
|
|
206
|
+
const weeklySeries = weekKeys.map((k) => {
|
|
207
|
+
const s = analyzeOvertime(weekGroups[k], {
|
|
208
|
+
startHour: opts.workStart || opts.workStart === 0 ? opts.workStart : 9,
|
|
209
|
+
endHour: opts.workEnd || opts.workEnd === 0 ? opts.workEnd : 18,
|
|
210
|
+
lunchStart: opts.lunchStart || opts.lunchStart === 0 ? opts.lunchStart : 12,
|
|
211
|
+
lunchEnd: opts.lunchEnd || opts.lunchEnd === 0 ? opts.lunchEnd : 14,
|
|
212
|
+
country: opts.country || 'CN',
|
|
213
|
+
});
|
|
214
|
+
return {
|
|
215
|
+
period: k,
|
|
216
|
+
total: s.total,
|
|
217
|
+
outsideWorkCount: s.outsideWorkCount,
|
|
218
|
+
outsideWorkRate: s.outsideWorkRate,
|
|
219
|
+
nonWorkdayCount: s.nonWorkdayCount,
|
|
220
|
+
nonWorkdayRate: s.nonWorkdayRate,
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
const dataWeeklyFile = outputFilePath('data/overtime-weekly.mjs', outDir);
|
|
224
|
+
const weeklyModule = `export default ${JSON.stringify(weeklySeries, null, 2)};\n`;
|
|
225
|
+
writeTextFile(dataWeeklyFile, weeklyModule);
|
|
226
|
+
console.log(chalk.green(`Weekly series 已导出: ${dataWeeklyFile}`));
|
|
227
|
+
|
|
228
|
+
startServer(opts.port || 3000, outDir).catch(() => {});
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.warn('Export data modules failed:', err && err.message ? err.message : err);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 按月输出 ... 保持原逻辑
|
|
182
235
|
const perPeriodFormats = String(opts.perPeriodFormats || '').split(',').map(s => String(s || '').trim().toLowerCase()).filter(Boolean);
|
|
183
236
|
try {
|
|
184
237
|
const monthGroups = groupRecords(records, 'month');
|
|
@@ -203,25 +256,25 @@ const opts = program.opts();
|
|
|
203
256
|
const perMonthFile = outputFilePath(perMonthFileName, outDir);
|
|
204
257
|
writeTextFile(perMonthFile, renderOvertimeText(s));
|
|
205
258
|
console.log(chalk.green(`Overtime 月度(${k}) 已导出: ${perMonthFile}`));
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
259
|
+
// per-period CSV / Tab format (按需生成)
|
|
260
|
+
if (perPeriodFormats.includes('csv')) {
|
|
261
|
+
try {
|
|
262
|
+
const perMonthCsvName = `month/overtime_${outBase}_${k}.csv`;
|
|
263
|
+
writeTextFile(outputFilePath(perMonthCsvName, outDir), renderOvertimeCsv(s));
|
|
264
|
+
console.log(chalk.green(`Overtime 月度(CSV)(${k}) 已导出: ${outputFilePath(perMonthCsvName, outDir)}`));
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.warn(`Write monthly CSV for ${k} failed:`, err && err.message ? err.message : err);
|
|
267
|
+
}
|
|
214
268
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
269
|
+
if (perPeriodFormats.includes('tab')) {
|
|
270
|
+
try {
|
|
271
|
+
const perMonthTabName = `month/overtime_${outBase}_${k}.tab.txt`;
|
|
272
|
+
writeTextFile(outputFilePath(perMonthTabName, outDir), renderOvertimeTab(s));
|
|
273
|
+
console.log(chalk.green(`Overtime 月度(Tab)(${k}) 已导出: ${outputFilePath(perMonthTabName, outDir)}`));
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.warn(`Write monthly Tab for ${k} failed:`, err && err.message ? err.message : err);
|
|
276
|
+
}
|
|
223
277
|
}
|
|
224
|
-
}
|
|
225
278
|
} catch (err) {
|
|
226
279
|
console.warn(`Write monthly file for ${k} failed:`, err && err.message ? err.message : err);
|
|
227
280
|
}
|
|
@@ -260,7 +313,8 @@ const opts = program.opts();
|
|
|
260
313
|
} catch (err) {
|
|
261
314
|
console.warn('Generate monthly overtime failed:', err && err.message ? err.message : err);
|
|
262
315
|
}
|
|
263
|
-
|
|
316
|
+
|
|
317
|
+
// 周度输出保持原逻辑(略)
|
|
264
318
|
try {
|
|
265
319
|
const weekGroups = groupRecords(records, 'week');
|
|
266
320
|
const weeklyFileName = `overtime_${outBase}_weekly.txt`;
|
|
@@ -278,119 +332,74 @@ const opts = program.opts();
|
|
|
278
332
|
});
|
|
279
333
|
weeklyContent += `===== ${k} =====\n`;
|
|
280
334
|
weeklyContent += `${renderOvertimeText(s)}\n\n`;
|
|
281
|
-
// Also write a single file per week under 'week/' folder
|
|
282
335
|
try {
|
|
283
336
|
const perWeekFileName = `week/overtime_${outBase}_${k}.txt`;
|
|
284
337
|
const perWeekFile = outputFilePath(perWeekFileName, outDir);
|
|
285
338
|
writeTextFile(perWeekFile, renderOvertimeText(s));
|
|
286
339
|
console.log(chalk.green(`Overtime 周度(${k}) 已导出: ${perWeekFile}`));
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
340
|
+
// eslint-disable-next-line no-shadow
|
|
341
|
+
const perPeriodFormats = String(opts.perPeriodFormats || '').split(',').map(s => String(s || '').trim().toLowerCase()).filter(Boolean);
|
|
342
|
+
if (perPeriodFormats.includes('csv')) {
|
|
343
|
+
try {
|
|
344
|
+
const perWeekCsvName = `week/overtime_${outBase}_${k}.csv`;
|
|
345
|
+
writeTextFile(outputFilePath(perWeekCsvName, outDir), renderOvertimeCsv(s));
|
|
346
|
+
console.log(chalk.green(`Overtime 周度(CSV)(${k}) 已导出: ${outputFilePath(perWeekCsvName, outDir)}`));
|
|
347
|
+
} catch (err) {
|
|
348
|
+
console.warn(`Write weekly CSV for ${k} failed:`, err && err.message ? err.message : err);
|
|
349
|
+
}
|
|
295
350
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
351
|
+
if (perPeriodFormats.includes('tab')) {
|
|
352
|
+
try {
|
|
353
|
+
const perWeekTabName = `week/overtime_${outBase}_${k}.tab.txt`;
|
|
354
|
+
writeTextFile(outputFilePath(perWeekTabName, outDir), renderOvertimeTab(s));
|
|
355
|
+
console.log(chalk.green(`Overtime 周度(Tab)(${k}) 已导出: ${outputFilePath(perWeekTabName, outDir)}`));
|
|
356
|
+
} catch (err) {
|
|
357
|
+
console.warn(`Write weekly Tab for ${k} failed:`, err && err.message ? err.message : err);
|
|
358
|
+
}
|
|
304
359
|
}
|
|
305
|
-
}
|
|
306
360
|
} catch (err) {
|
|
307
361
|
console.warn(`Write weekly file for ${k} failed:`, err && err.message ? err.message : err);
|
|
308
362
|
}
|
|
309
363
|
});
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
console.log(chalk.green(`Overtime 周度汇总 已导出: ${weeklyFile}`));
|
|
313
|
-
}
|
|
314
|
-
// per-period Excel (sheets or files)
|
|
315
|
-
if (perPeriodFormats.includes('xlsx')) {
|
|
316
|
-
const perPeriodExcelMode = String(opts.perPeriodExcelMode || 'sheets');
|
|
317
|
-
if (perPeriodExcelMode === 'sheets') {
|
|
318
|
-
try {
|
|
319
|
-
const weekXlsxName = `week/overtime_${outBase}_weekly.xlsx`;
|
|
320
|
-
const weekXlsxFile = outputFilePath(weekXlsxName, outDir);
|
|
321
|
-
await exportExcelPerPeriodSheets(weekGroups, weekXlsxFile, { stats: opts.stats, gerrit: opts.gerrit });
|
|
322
|
-
console.log(chalk.green(`Overtime 周度(XLSX) 已导出: ${weekXlsxFile}`));
|
|
323
|
-
} catch (err) {
|
|
324
|
-
console.warn('Export week XLSX (sheets) failed:', err && err.message ? err.message : err);
|
|
325
|
-
}
|
|
326
|
-
} else {
|
|
327
|
-
try {
|
|
328
|
-
const weekKeys2 = Object.keys(weekGroups).sort();
|
|
329
|
-
const tasks2 = weekKeys2.map(k2 => {
|
|
330
|
-
const perWeekXlsxName = `week/overtime_${outBase}_${k2}.xlsx`;
|
|
331
|
-
const perWeekXlsxFile = outputFilePath(perWeekXlsxName, outDir);
|
|
332
|
-
return exportExcel(weekGroups[k2], null, { file: perWeekXlsxFile, stats: opts.stats, gerrit: opts.gerrit })
|
|
333
|
-
.then(() => console.log(chalk.green(`Overtime 周度(XLSX)(${k2}) 已导出: ${perWeekXlsxFile}`)));
|
|
334
|
-
});
|
|
335
|
-
await Promise.all(tasks2);
|
|
336
|
-
} catch (err) {
|
|
337
|
-
console.warn('Export weekly XLSX files failed:', err && err.message ? err.message : err);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
364
|
+
writeTextFile(weeklyFile, weeklyContent);
|
|
365
|
+
console.log(chalk.green(`Overtime 周度汇总 已导出: ${weeklyFile}`));
|
|
341
366
|
} catch (err) {
|
|
342
367
|
console.warn('Generate weekly overtime failed:', err && err.message ? err.message : err);
|
|
343
368
|
}
|
|
344
|
-
// don't return — allow other outputs to proceed
|
|
345
369
|
}
|
|
346
370
|
|
|
347
|
-
// --- JSON ---
|
|
371
|
+
// --- JSON/TEXT/EXCEL(保持原逻辑) ---
|
|
348
372
|
if (opts.json || opts.format === 'json') {
|
|
349
373
|
const file = opts.out || 'commits.json';
|
|
350
374
|
const filepath = outputFilePath(file, outDir);
|
|
351
|
-
|
|
352
375
|
writeJSON(filepath, groups || records);
|
|
353
376
|
console.log(chalk.green(`JSON 已导出: ${filepath}`));
|
|
354
377
|
return;
|
|
355
378
|
}
|
|
356
379
|
|
|
357
|
-
// --- TEXT ---
|
|
358
380
|
if (opts.format === 'text') {
|
|
359
381
|
const file = opts.out || 'commits.txt';
|
|
360
382
|
const filepath = outputFilePath(file, outDir);
|
|
361
|
-
|
|
362
383
|
const text = renderText(records, groups, { showGerrit: !!opts.gerrit });
|
|
363
384
|
writeTextFile(filepath, text);
|
|
364
|
-
|
|
365
385
|
console.log(text);
|
|
366
386
|
console.log(chalk.green(`文本已导出: ${filepath}`));
|
|
367
387
|
return;
|
|
368
388
|
}
|
|
369
389
|
|
|
370
|
-
// --- EXCEL(强制同时输出 TXT) ---
|
|
371
390
|
if (opts.format === 'excel') {
|
|
372
|
-
// Excel
|
|
373
391
|
const excelFile = opts.out || 'commits.xlsx';
|
|
374
392
|
const excelPath = outputFilePath(excelFile, outDir);
|
|
375
|
-
|
|
376
|
-
// TXT(自动附带)
|
|
377
393
|
const txtFile = excelFile.replace(/\.xlsx$/, '.txt');
|
|
378
394
|
const txtPath = outputFilePath(txtFile, outDir);
|
|
379
|
-
|
|
380
|
-
// 导出 Excel 文件
|
|
381
395
|
await exportExcel(records, groups, {
|
|
382
396
|
file: excelPath,
|
|
383
397
|
stats: opts.stats,
|
|
384
398
|
gerrit: opts.gerrit
|
|
385
399
|
});
|
|
386
|
-
|
|
387
|
-
// 导出文本
|
|
388
400
|
const text = renderText(records, groups);
|
|
389
401
|
writeTextFile(txtPath, text);
|
|
390
|
-
|
|
391
402
|
console.log(chalk.green(`Excel 已导出: ${excelPath}`));
|
|
392
403
|
console.log(chalk.green(`文本已自动导出: ${txtPath}`));
|
|
393
|
-
|
|
394
|
-
|
|
395
404
|
}
|
|
396
405
|
})();
|
package/src/overtime.mjs
CHANGED
|
@@ -64,8 +64,10 @@ export function analyzeOvertime(records, opts = {}) {
|
|
|
64
64
|
hd.init('CN');
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
//
|
|
67
|
+
// 新增:每小时分布统计(全部提交)
|
|
68
68
|
const hourlyCommits = Array(24).fill(0);
|
|
69
|
+
// 新增:每小时分布统计(仅加班提交)
|
|
70
|
+
const hourlyOvertimeCommits = Array(24).fill(0);
|
|
69
71
|
records.forEach((r) => {
|
|
70
72
|
const dt = parseCommitDate(r.date);
|
|
71
73
|
if (!dt || !dt.isValid()) return; // skip
|
|
@@ -74,10 +76,13 @@ export function analyzeOvertime(records, opts = {}) {
|
|
|
74
76
|
const isHoliday = !!hd.isHoliday(dt.toDate());
|
|
75
77
|
const isNonWork = isWeekend(dt) || isHoliday;
|
|
76
78
|
|
|
77
|
-
//
|
|
79
|
+
// 每小时分布(全部提交)
|
|
78
80
|
const hour = dt.hour();
|
|
79
81
|
if (hour >= 0 && hour < 24) {
|
|
80
82
|
hourlyCommits[hour]++;
|
|
83
|
+
if (outside) {
|
|
84
|
+
hourlyOvertimeCommits[hour]++;
|
|
85
|
+
}
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
if (outside) {
|
|
@@ -142,8 +147,10 @@ export function analyzeOvertime(records, opts = {}) {
|
|
|
142
147
|
// sort perAuthor by outsideWorkRate desc
|
|
143
148
|
perAuthor.sort((a, b) => b.outsideWorkRate - a.outsideWorkRate || b.total - a.total);
|
|
144
149
|
|
|
145
|
-
//
|
|
150
|
+
// 新增:每小时分布百分比(全部提交)
|
|
146
151
|
const hourlyPercent = hourlyCommits.map(v => total ? +(v / total).toFixed(3) : 0);
|
|
152
|
+
// 新增:每小时分布百分比(仅加班提交)
|
|
153
|
+
const hourlyOvertimePercent = hourlyOvertimeCommits.map(v => total ? +(v / total).toFixed(3) : 0);
|
|
147
154
|
return {
|
|
148
155
|
total,
|
|
149
156
|
outsideWorkCount,
|
|
@@ -165,11 +172,13 @@ export function analyzeOvertime(records, opts = {}) {
|
|
|
165
172
|
holidayRate: total ? +(holidayCount / total).toFixed(3) : 0,
|
|
166
173
|
hourlyCommits,
|
|
167
174
|
hourlyPercent,
|
|
175
|
+
hourlyOvertimeCommits,
|
|
176
|
+
hourlyOvertimePercent,
|
|
168
177
|
};
|
|
169
178
|
}
|
|
170
179
|
|
|
171
180
|
export function renderOvertimeText(stats) {
|
|
172
|
-
const { total, outsideWorkCount, nonWorkdayCount, holidayCount, outsideWorkRate, nonWorkdayRate, holidayRate, perAuthor, startHour, endHour, lunchStart, lunchEnd, country,
|
|
181
|
+
const { total, outsideWorkCount, nonWorkdayCount, holidayCount, outsideWorkRate, nonWorkdayRate, holidayRate, perAuthor, startHour, endHour, lunchStart, lunchEnd, country, hourlyOvertimeCommits = [], hourlyOvertimePercent = [] } = stats;
|
|
173
182
|
const { startCommit, endCommit, latestCommit, latestOutsideCommit } = stats;
|
|
174
183
|
const lines = [];
|
|
175
184
|
|
|
@@ -218,15 +227,15 @@ export function renderOvertimeText(stats) {
|
|
|
218
227
|
lines.push(`下班时间(工作时间外)提交数:${outsideWorkCount},占比:${(outsideWorkRate * 100).toFixed(1)}%`);
|
|
219
228
|
lines.push(`非工作日(周末)提交数:${nonWorkdayCount},占比:${(nonWorkdayRate * 100).toFixed(1)}%`);
|
|
220
229
|
lines.push('');
|
|
221
|
-
lines.push('
|
|
222
|
-
const hourLine = ' Hour |
|
|
230
|
+
lines.push('每小时加班分布(工作时间外提交数/占比):');
|
|
231
|
+
const hourLine = ' Hour | OvertimeCount | Percent';
|
|
223
232
|
lines.push(hourLine);
|
|
224
|
-
lines.push('
|
|
233
|
+
lines.push(' -----|--------------|--------');
|
|
225
234
|
for (let h = 0; h < 24; h++) {
|
|
226
|
-
const cnt =
|
|
235
|
+
const cnt = hourlyOvertimeCommits[h] || 0;
|
|
227
236
|
if (cnt > 0) {
|
|
228
|
-
const pct =
|
|
229
|
-
lines.push(` ${String(h).padStart(2, '0')} | ${String(cnt).padStart(
|
|
237
|
+
const pct = hourlyOvertimePercent[h] ? (hourlyOvertimePercent[h] * 100).toFixed(1) : '0.0';
|
|
238
|
+
lines.push(` ${String(h).padStart(2, '0')} | ${String(cnt).padStart(12, ' ')} | ${pct.padStart(6, ' ')}%`);
|
|
230
239
|
}
|
|
231
240
|
}
|
|
232
241
|
lines.push('');
|
|
@@ -248,7 +257,7 @@ export function renderOvertimeText(stats) {
|
|
|
248
257
|
}
|
|
249
258
|
|
|
250
259
|
export function renderOvertimeTab(stats) {
|
|
251
|
-
const { total, outsideWorkCount, nonWorkdayCount, holidayCount, outsideWorkRate, nonWorkdayRate, holidayRate, perAuthor, startHour, endHour, lunchStart, lunchEnd, country,
|
|
260
|
+
const { total, outsideWorkCount, nonWorkdayCount, holidayCount, outsideWorkRate, nonWorkdayRate, holidayRate, perAuthor, startHour, endHour, lunchStart, lunchEnd, country, hourlyOvertimeCommits = [], hourlyOvertimePercent = [] } = stats;
|
|
252
261
|
const { startCommit, endCommit, latestCommit, latestOutsideCommit } = stats;
|
|
253
262
|
const rows = [];
|
|
254
263
|
rows.push(`总提交数:\t${total}`);
|
|
@@ -260,12 +269,12 @@ export function renderOvertimeTab(stats) {
|
|
|
260
269
|
rows.push(`下班时间(工作时间外)提交数:\t${outsideWorkCount}\t占比:\t${(outsideWorkRate * 100).toFixed(1)}%`);
|
|
261
270
|
rows.push(`非工作日(周末)提交数:\t${nonWorkdayCount}\t占比:\t${(nonWorkdayRate * 100).toFixed(1)}%`);
|
|
262
271
|
rows.push('');
|
|
263
|
-
rows.push('
|
|
264
|
-
rows.push(['Hour', '
|
|
272
|
+
rows.push('每小时加班分布(工作时间外提交数/占比):');
|
|
273
|
+
rows.push(['Hour', 'OvertimeCount', 'Percent'].join('\t'));
|
|
265
274
|
for (let h = 0; h < 24; h++) {
|
|
266
|
-
const cnt =
|
|
275
|
+
const cnt = hourlyOvertimeCommits[h] || 0;
|
|
267
276
|
if (cnt > 0) {
|
|
268
|
-
const pct =
|
|
277
|
+
const pct = hourlyOvertimePercent[h] ? (hourlyOvertimePercent[h] * 100).toFixed(1) : '0.0';
|
|
269
278
|
rows.push([String(h).padStart(2, '0'), cnt, `${pct}%`].join('\t'));
|
|
270
279
|
}
|
|
271
280
|
}
|
|
@@ -286,18 +295,18 @@ function escapeCsv(v) {
|
|
|
286
295
|
}
|
|
287
296
|
|
|
288
297
|
export function renderOvertimeCsv(stats) {
|
|
289
|
-
const { perAuthor, latestOutsideCommit, country,
|
|
298
|
+
const { perAuthor, latestOutsideCommit, country, hourlyOvertimeCommits = [], hourlyOvertimePercent = [], total = 0 } = stats;
|
|
290
299
|
const rows = [];
|
|
291
300
|
if (latestOutsideCommit) {
|
|
292
301
|
rows.push(`# 加班最晚的一次提交,Hash,Author,Date,Message`);
|
|
293
302
|
rows.push(`# ,${escapeCsv(latestOutsideCommit.hash)},${escapeCsv(latestOutsideCommit.author)},${escapeCsv(formatDateForCountry(latestOutsideCommit.date, country))},${escapeCsv(latestOutsideCommit.message)}`);
|
|
294
303
|
}
|
|
295
304
|
rows.push('');
|
|
296
|
-
rows.push('Hour,
|
|
305
|
+
rows.push('Hour,OvertimeCount,Percent');
|
|
297
306
|
for (let h = 0; h < 24; h++) {
|
|
298
|
-
const cnt =
|
|
307
|
+
const cnt = hourlyOvertimeCommits[h] || 0;
|
|
299
308
|
if (cnt > 0) {
|
|
300
|
-
const pct =
|
|
309
|
+
const pct = hourlyOvertimePercent[h] ? (hourlyOvertimePercent[h] * 100).toFixed(1) : '0.0';
|
|
301
310
|
rows.push(`${h.toString().padStart(2, '0')},${cnt},${pct}%`);
|
|
302
311
|
}
|
|
303
312
|
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
|
|
6
|
+
const mime = new Map([
|
|
7
|
+
['.html', 'text/html; charset=utf-8'],
|
|
8
|
+
['.js', 'application/javascript; charset=utf-8'],
|
|
9
|
+
['.mjs', 'application/javascript; charset=utf-8'],
|
|
10
|
+
['.css', 'text/css; charset=utf-8'],
|
|
11
|
+
['.json', 'application/json; charset=utf-8'],
|
|
12
|
+
['.svg', 'image/svg+xml'],
|
|
13
|
+
['.png', 'image/png'],
|
|
14
|
+
['.jpg', 'image/jpeg'],
|
|
15
|
+
['.jpeg', 'image/jpeg'],
|
|
16
|
+
['.map', 'application/json; charset=utf-8'],
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
// eslint-disable-next-line default-param-last
|
|
20
|
+
export function startServer(port = 3000, outputDir) {
|
|
21
|
+
// TODO: remove debug log before production
|
|
22
|
+
console.log('✅', 'outputDir', outputDir);
|
|
23
|
+
const webRoot = path.resolve(process.cwd(), 'web');
|
|
24
|
+
const dataRoot = outputDir ? path.resolve(outputDir) : path.resolve(process.cwd(), 'output');
|
|
25
|
+
|
|
26
|
+
// warn if web directory or data directory doesn't exist
|
|
27
|
+
if (!fs.existsSync(webRoot)) {
|
|
28
|
+
console.warn(chalk.yellow(`Warning: web/ directory not found at ${webRoot}. Server will still run but no UI will be available.`));
|
|
29
|
+
}
|
|
30
|
+
if (!fs.existsSync(dataRoot)) {
|
|
31
|
+
console.warn(chalk.yellow(`Warning: output data directory not found at ${dataRoot}. Server will still run but data endpoints (/data/) may 404.`));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const server = http.createServer((req, res) => {
|
|
35
|
+
try {
|
|
36
|
+
// Normalize URL path
|
|
37
|
+
const u = new URL(req.url, `http://localhost`);
|
|
38
|
+
let pathname = decodeURIComponent(u.pathname);
|
|
39
|
+
|
|
40
|
+
// Serve data files under /data/* mapped to dataRoot/data/*
|
|
41
|
+
if (pathname.startsWith('/data/')) {
|
|
42
|
+
const relative = pathname.replace(/^\/data\//, '');
|
|
43
|
+
const fileLocal = path.join(dataRoot, 'data', relative);
|
|
44
|
+
if (fs.existsSync(fileLocal) && fs.statSync(fileLocal).isFile()) {
|
|
45
|
+
const ext = path.extname(fileLocal).toLowerCase();
|
|
46
|
+
res.setHeader('Content-Type', mime.get(ext) || 'application/octet-stream');
|
|
47
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
48
|
+
const stream = fs.createReadStream(fileLocal);
|
|
49
|
+
stream.pipe(res);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Resolve web assets
|
|
55
|
+
if (pathname === '/') pathname = '/index.html';
|
|
56
|
+
const fileLocal = path.join(webRoot, pathname);
|
|
57
|
+
if (fs.existsSync(fileLocal) && fs.statSync(fileLocal).isFile()) {
|
|
58
|
+
const ext = path.extname(fileLocal).toLowerCase();
|
|
59
|
+
res.setHeader('Content-Type', mime.get(ext) || 'application/octet-stream');
|
|
60
|
+
const stream = fs.createReadStream(fileLocal);
|
|
61
|
+
stream.pipe(res);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// file not found
|
|
66
|
+
res.statusCode = 404;
|
|
67
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
68
|
+
res.end('Not Found');
|
|
69
|
+
} catch (err) {
|
|
70
|
+
res.statusCode = 500;
|
|
71
|
+
res.end('Server error');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
server.on('error', (err) => reject(err));
|
|
77
|
+
server.listen(port, () => {
|
|
78
|
+
console.log(chalk.green(`Server started at http://localhost:${port}`));
|
|
79
|
+
console.log(chalk.green(`Serving web/ and output/data/`));
|
|
80
|
+
resolve(server);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default startServer;
|
package/web/app.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/* global Chart */
|
|
2
|
+
/* eslint-disable import/no-absolute-path */
|
|
3
|
+
const formatDate = (d) => new Date(d).toLocaleString();
|
|
4
|
+
|
|
5
|
+
async function loadData() {
|
|
6
|
+
try {
|
|
7
|
+
const [commitsModule, statsModule, weeklyModule] = await Promise.all([
|
|
8
|
+
import("/data/commits.mjs"),
|
|
9
|
+
import("/data/overtime-stats.mjs"),
|
|
10
|
+
import("/data/overtime-weekly.mjs"),
|
|
11
|
+
]);
|
|
12
|
+
const commits = commitsModule.default || [];
|
|
13
|
+
const stats = statsModule.default || {};
|
|
14
|
+
const weekly = weeklyModule.default || [];
|
|
15
|
+
return { commits, stats, weekly };
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.error('Load data failed', err);
|
|
18
|
+
return { commits: [], stats: {}, weekly: [] };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderCommitsTable(commits) {
|
|
23
|
+
const tbody = document.querySelector('#commitsTable tbody');
|
|
24
|
+
tbody.innerHTML = '';
|
|
25
|
+
commits.forEach((c) => {
|
|
26
|
+
const tr = document.createElement('tr');
|
|
27
|
+
tr.innerHTML = `<td>${c.hash.slice(0, 8)}</td><td>${c.author}</td><td>${formatDate(c.date)}</td><td>${c.message}</td>`;
|
|
28
|
+
tbody.appendChild(tr);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function drawHourlyOvertime(stats) {
|
|
33
|
+
const ctx = document.getElementById('hourlyOvertimeChart').getContext('2d');
|
|
34
|
+
const data = stats.hourlyOvertimeCommits || [];
|
|
35
|
+
const labels = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
|
36
|
+
const chartHourly = new Chart(ctx, {
|
|
37
|
+
type: 'bar',
|
|
38
|
+
data: { labels, datasets: [{ label: 'Overtime commits', data }] },
|
|
39
|
+
options: { responsive: true }
|
|
40
|
+
});
|
|
41
|
+
return chartHourly;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function drawOutsideVsInside(stats) {
|
|
45
|
+
const ctx = document.getElementById('outsideVsInsideChart').getContext('2d');
|
|
46
|
+
const outside = stats.outsideWorkCount || 0;
|
|
47
|
+
const total = stats.total || 0;
|
|
48
|
+
const inside = Math.max(0, total - outside);
|
|
49
|
+
const chartPie = new Chart(ctx, {
|
|
50
|
+
type: 'pie',
|
|
51
|
+
data: { labels: ['Inside work', 'Outside work'], datasets: [{ data: [inside, outside], backgroundColor: ['#36a2eb', '#ff6384'] }] },
|
|
52
|
+
});
|
|
53
|
+
return chartPie;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function drawDailyTrend(commits) {
|
|
57
|
+
const map = new Map();
|
|
58
|
+
commits.forEach((c) => {
|
|
59
|
+
const d = new Date(c.date).toISOString().slice(0, 10);
|
|
60
|
+
map.set(d, (map.get(d) || 0) + 1);
|
|
61
|
+
});
|
|
62
|
+
const labels = Array.from(map.keys()).sort();
|
|
63
|
+
const data = labels.map(l => map.get(l));
|
|
64
|
+
const ctx = document.getElementById('dailyTrendChart').getContext('2d');
|
|
65
|
+
const chartDaily = new Chart(ctx, {
|
|
66
|
+
type: 'line',
|
|
67
|
+
data: { labels, datasets: [{ label: 'Commits per day', data, fill: true, tension: 0.3 }] },
|
|
68
|
+
});
|
|
69
|
+
return chartDaily;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function drawWeeklyTrend(weekly) {
|
|
73
|
+
const labels = weekly.map(w => w.period);
|
|
74
|
+
const dataRate = weekly.map(w => +(w.outsideWorkRate * 100).toFixed(1));
|
|
75
|
+
const dataCount = weekly.map(w => w.outsideWorkCount);
|
|
76
|
+
const ctx = document.getElementById('weeklyTrendChart').getContext('2d');
|
|
77
|
+
const chartWeekly = new Chart(ctx, {
|
|
78
|
+
type: 'line',
|
|
79
|
+
data: {
|
|
80
|
+
labels,
|
|
81
|
+
datasets: [
|
|
82
|
+
{ label: '加班占比(%)', data: dataRate, borderColor: '#ff6384', yAxisID: 'y1' },
|
|
83
|
+
{ label: '加班次数', data: dataCount, borderColor: '#36a2eb', yAxisID: 'y2' },
|
|
84
|
+
]
|
|
85
|
+
},
|
|
86
|
+
options: {
|
|
87
|
+
responsive: true,
|
|
88
|
+
scales: {
|
|
89
|
+
y1: { type: 'linear', position: 'left', min: 0, max: 100 },
|
|
90
|
+
y2: { type: 'linear', position: 'right' }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return chartWeekly;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
(async function main() {
|
|
98
|
+
const { commits, stats, weekly } = await loadData();
|
|
99
|
+
renderCommitsTable(commits);
|
|
100
|
+
drawHourlyOvertime(stats);
|
|
101
|
+
drawOutsideVsInside(stats);
|
|
102
|
+
drawDailyTrend(commits);
|
|
103
|
+
drawWeeklyTrend(weekly);
|
|
104
|
+
})();
|
package/web/index.html
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
6
|
+
<title>wukong-gitlog-cli — Overtime Dashboard</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css" />
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header>
|
|
12
|
+
<h1>Wukong Gitlog Overtime Dashboard</h1>
|
|
13
|
+
</header>
|
|
14
|
+
<main>
|
|
15
|
+
<section id="charts">
|
|
16
|
+
<div class="chart-card">
|
|
17
|
+
<h2>每小时加班分布 (小时 -> 提交数)</h2>
|
|
18
|
+
<canvas id="hourlyOvertimeChart"></canvas>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="chart-card">
|
|
21
|
+
<h2>下班时间 vs 工作时间提交占比</h2>
|
|
22
|
+
<canvas id="outsideVsInsideChart"></canvas>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="chart-card">
|
|
25
|
+
<h2>按日提交趋势</h2>
|
|
26
|
+
<canvas id="dailyTrendChart"></canvas>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="chart-card">
|
|
29
|
+
<h2>每周趋势(加班占比)</h2>
|
|
30
|
+
<canvas id="weeklyTrendChart"></canvas>
|
|
31
|
+
</div>
|
|
32
|
+
</section>
|
|
33
|
+
|
|
34
|
+
<section class="table-card">
|
|
35
|
+
<h2>提交清单</h2>
|
|
36
|
+
<table id="commitsTable">
|
|
37
|
+
<thead>
|
|
38
|
+
<tr><th>Hash</th><th>Author</th><th>Date</th><th>Message</th></tr>
|
|
39
|
+
</thead>
|
|
40
|
+
<tbody></tbody>
|
|
41
|
+
</table>
|
|
42
|
+
</section>
|
|
43
|
+
</main>
|
|
44
|
+
<footer>
|
|
45
|
+
<small>Local dashboard generated by wukong-gitlog-cli. Data served from <code>/data/</code>.</small>
|
|
46
|
+
</footer>
|
|
47
|
+
<script type="module" src="/app.js"></script>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
package/web/style.css
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; margin: 0; padding: 0; background: #fafafa; color: #222; }
|
|
2
|
+
header { background: #0f172a; color: white; padding: 12px 20px; }
|
|
3
|
+
main { display: flex; flex-direction: column; gap: 12px; padding: 20px; max-width: 1200px; margin: 20px auto; }
|
|
4
|
+
#charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
|
|
5
|
+
.chart-card { background: white; border-radius: 8px; padding: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
|
6
|
+
.table-card { background: white; padding: 12px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
|
7
|
+
table { width: 100%; border-collapse: collapse; }
|
|
8
|
+
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #eee; }
|
|
9
|
+
footer { text-align: center; margin: 8px 0 24px 0; color: #666; }
|