wukong-gitlog-cli 0.0.11 → 0.0.13

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 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.13](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v0.0.12...v0.0.13) (2025-11-28)
6
+
7
+
8
+ ### Features
9
+
10
+ * 🎸 web dir ([aaadd05](https://github.com/tomatobybike/wukong-gitlog-cli/commit/aaadd051a8216d75ad6d8517b292caac90c7fb9b))
11
+
12
+ ### [0.0.12](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v0.0.11...v0.0.12) (2025-11-28)
13
+
14
+
15
+ ### Features
16
+
17
+ * 🎸 add web server ([b2e3d80](https://github.com/tomatobybike/wukong-gitlog-cli/commit/b2e3d80d1a998a07b289a2300d2ec53cd0d586ad))
18
+ * 🎸 create web ([3e2da00](https://github.com/tomatobybike/wukong-gitlog-cli/commit/3e2da00222e74199eea6e324e15008e2bb2be127))
19
+ * 🎸 fix eslint ([2666eed](https://github.com/tomatobybike/wukong-gitlog-cli/commit/2666eedd2b41dc1aa88cb9bb3e12a45ec0390f0b))
20
+ * 🎸 web ([6feac90](https://github.com/tomatobybike/wukong-gitlog-cli/commit/6feac90f85a9142bb0e68b98250f7755603086d9))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * 🐛 eslint error ([cb65012](https://github.com/tomatobybike/wukong-gitlog-cli/commit/cb65012589f543720d355505f3d8693fd573ee64))
26
+
5
27
  ### [0.0.11](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v0.0.10...v0.0.11) (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.11",
3
+ "version": "0.0.13",
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
- console.warn('prefix contains {{changeNumber}} but no --gerrit-api provided — falling back to changeId/hash');
124
- records = records.map((r) => ({ ...r, gerrit: prefix.replace('{{changeNumber}}', r.changeId || r.hash) }));
125
- } else {
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
- let gerritUrl;
128
- if (prefix.includes('{{changeId}}')) {
129
- const changeId = r.changeId || r.hash;
130
- gerritUrl = prefix.replace('{{changeId}}', changeId);
131
- } else if (prefix.includes('{{hash}}')) {
132
- gerritUrl = prefix.replace('{{hash}}', r.hash);
133
- } else {
134
- // append hash to prefix, ensure slash handling
135
- gerritUrl = prefix.endsWith('/') ? `${prefix}${r.hash}` : `${prefix}/${r.hash}`;
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
- // 按月输出每个月的加班统计(合并文件 + individual files in month/)
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
- // per-period CSV / Tab format (按需生成)
207
- if (perPeriodFormats.includes('csv')) {
208
- try {
209
- const perMonthCsvName = `month/overtime_${outBase}_${k}.csv`;
210
- writeTextFile(outputFilePath(perMonthCsvName, outDir), renderOvertimeCsv(s));
211
- console.log(chalk.green(`Overtime 月度(CSV)(${k}) 已导出: ${outputFilePath(perMonthCsvName, outDir)}`));
212
- } catch (err) {
213
- console.warn(`Write monthly CSV for ${k} failed:`, err && err.message ? err.message : err);
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
- if (perPeriodFormats.includes('tab')) {
217
- try {
218
- const perMonthTabName = `month/overtime_${outBase}_${k}.tab.txt`;
219
- writeTextFile(outputFilePath(perMonthTabName, outDir), renderOvertimeTab(s));
220
- console.log(chalk.green(`Overtime 月度(Tab)(${k}) 已导出: ${outputFilePath(perMonthTabName, outDir)}`));
221
- } catch (err) {
222
- console.warn(`Write monthly Tab for ${k} failed:`, err && err.message ? err.message : err);
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
- // 按周输出每周的加班统计(合并文件 + individual files in week/)
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
- // per-period CSV / Tab format (按需生成)
288
- if (perPeriodFormats.includes('csv')) {
289
- try {
290
- const perWeekCsvName = `week/overtime_${outBase}_${k}.csv`;
291
- writeTextFile(outputFilePath(perWeekCsvName, outDir), renderOvertimeCsv(s));
292
- console.log(chalk.green(`Overtime 周度(CSV)(${k}) 已导出: ${outputFilePath(perWeekCsvName, outDir)}`));
293
- } catch (err) {
294
- console.warn(`Write weekly CSV for ${k} failed:`, err && err.message ? err.message : err);
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
- if (perPeriodFormats.includes('tab')) {
298
- try {
299
- const perWeekTabName = `week/overtime_${outBase}_${k}.tab.txt`;
300
- writeTextFile(outputFilePath(perWeekTabName, outDir), renderOvertimeTab(s));
301
- console.log(chalk.green(`Overtime 周度(Tab)(${k}) 已导出: ${outputFilePath(perWeekTabName, outDir)}`));
302
- } catch (err) {
303
- console.warn(`Write weekly Tab for ${k} failed:`, err && err.message ? err.message : err);
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
- if (!opts.perPeriodOnly) {
311
- writeTextFile(weeklyFile, weeklyContent);
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/server.mjs ADDED
@@ -0,0 +1,87 @@
1
+ import http from 'http';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import chalk from 'chalk';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const mime = new Map([
8
+ ['.html', 'text/html; charset=utf-8'],
9
+ ['.js', 'application/javascript; charset=utf-8'],
10
+ ['.mjs', 'application/javascript; charset=utf-8'],
11
+ ['.css', 'text/css; charset=utf-8'],
12
+ ['.json', 'application/json; charset=utf-8'],
13
+ ['.svg', 'image/svg+xml'],
14
+ ['.png', 'image/png'],
15
+ ['.jpg', 'image/jpeg'],
16
+ ['.jpeg', 'image/jpeg'],
17
+ ['.map', 'application/json; charset=utf-8'],
18
+ ]);
19
+
20
+ // eslint-disable-next-line default-param-last
21
+ export function startServer(port = 3000, outputDir) {
22
+ // 解析包根目录,确保 web 资源在全局安装后也能找到
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const pkgRoot = path.resolve(__dirname, '..');
25
+ const webRoot = path.resolve(pkgRoot, 'web');
26
+ const dataRoot = outputDir ? path.resolve(outputDir) : path.resolve(process.cwd(), 'output');
27
+
28
+ // warn if web directory or data directory doesn't exist
29
+ if (!fs.existsSync(webRoot)) {
30
+ console.warn(chalk.yellow(`Warning: web/ directory not found at ${webRoot}. Server will still run but no UI will be available.`));
31
+ }
32
+ if (!fs.existsSync(dataRoot)) {
33
+ console.warn(chalk.yellow(`Warning: output data directory not found at ${dataRoot}. Server will still run but data endpoints (/data/) may 404.`));
34
+ }
35
+
36
+ const server = http.createServer((req, res) => {
37
+ try {
38
+ // Normalize URL path
39
+ const u = new URL(req.url, `http://localhost`);
40
+ let pathname = decodeURIComponent(u.pathname);
41
+
42
+ // Serve data files under /data/* mapped to dataRoot/data/*
43
+ if (pathname.startsWith('/data/')) {
44
+ const relative = pathname.replace(/^\/data\//, '');
45
+ const fileLocal = path.join(dataRoot, 'data', relative);
46
+ if (fs.existsSync(fileLocal) && fs.statSync(fileLocal).isFile()) {
47
+ const ext = path.extname(fileLocal).toLowerCase();
48
+ res.setHeader('Content-Type', mime.get(ext) || 'application/octet-stream');
49
+ res.setHeader('Access-Control-Allow-Origin', '*');
50
+ const stream = fs.createReadStream(fileLocal);
51
+ stream.pipe(res);
52
+ return;
53
+ }
54
+ }
55
+
56
+ // Resolve web assets
57
+ if (pathname === '/') pathname = '/index.html';
58
+ const fileLocal = path.join(webRoot, pathname);
59
+ if (fs.existsSync(fileLocal) && fs.statSync(fileLocal).isFile()) {
60
+ const ext = path.extname(fileLocal).toLowerCase();
61
+ res.setHeader('Content-Type', mime.get(ext) || 'application/octet-stream');
62
+ const stream = fs.createReadStream(fileLocal);
63
+ stream.pipe(res);
64
+ return;
65
+ }
66
+
67
+ // file not found
68
+ res.statusCode = 404;
69
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
70
+ res.end('Not Found');
71
+ } catch (err) {
72
+ res.statusCode = 500;
73
+ res.end('Server error');
74
+ }
75
+ });
76
+
77
+ return new Promise((resolve, reject) => {
78
+ server.on('error', (err) => reject(err));
79
+ server.listen(port, () => {
80
+ console.log(chalk.green(`Server started at http://localhost:${port}`));
81
+ console.log(chalk.green(`Serving web/ and output/data/`));
82
+ resolve(server);
83
+ });
84
+ });
85
+ }
86
+
87
+ 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; }