wukong-gitlog-cli 1.0.39 → 1.0.40
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/.eslintrc +1 -0
- package/.prettierrc +2 -1
- package/CHANGELOG.md +95 -0
- package/README.md +93 -173
- package/README.zh-CN.md +85 -137
- package/bin/wukong-gitlog-cli +0 -0
- package/doc//347/233/256/345/275/225/347/273/223/346/236/204.md +2871 -0
- package/package.json +32 -29
- package/rc/.wukonggitlogrc +53 -0
- package/scripts/compareHourlyCounts.mjs +42 -0
- package/scripts/compareLatest.mjs +106 -0
- package/src/app/analyzeAction.mjs +120 -0
- package/src/app/exportAction.mjs +215 -0
- package/src/app/exportActionProgress.mjs +37 -0
- package/src/app/helpers.mjs +292 -0
- package/src/app/initAction.mjs +110 -0
- package/src/app/initActionWithTemp.mjs +192 -0
- package/src/app/journalAction.mjs +117 -0
- package/src/app/overtimeAction.mjs +100 -0
- package/src/app/runProfileEnd.mjs +0 -0
- package/src/app/serveAction.mjs +73 -0
- package/src/app/versionAction.mjs +7 -0
- package/src/cli/defineOptions.mjs +209 -0
- package/src/cli/index.mjs +0 -0
- package/src/cli/parseOptions.mjs +126 -8
- package/src/constants/index.mjs +16 -2
- package/src/domain/author/analyze.mjs +6 -0
- package/src/domain/author/map.mjs +0 -0
- package/src/domain/export/exportAuthor.mjs +28 -0
- package/src/domain/export/exportAuthorChanges.mjs +27 -0
- package/src/domain/export/exportAuthorChangesJson.mjs +31 -0
- package/src/domain/export/exportByMonth.mjs +157 -0
- package/src/domain/export/exportByWeek.mjs +121 -0
- package/src/domain/export/exportCommits.mjs +26 -0
- package/src/domain/export/exportCommitsExcel.mjs +45 -0
- package/src/domain/export/exportCommitsJson.mjs +31 -0
- package/src/domain/export/index.mjs +91 -0
- package/src/domain/git/ensureGitAvailable.mjs +66 -0
- package/src/domain/git/ensureGitRepo.mjs +41 -0
- package/src/domain/git/getGitFeatures.mjs +59 -0
- package/src/domain/git/getGitLogs.mjs +326 -0
- package/src/domain/git/getGitUser.mjs +44 -0
- package/src/domain/git/getRepoRoot.mjs +32 -0
- package/src/domain/git/gitCapability.mjs +119 -0
- package/src/domain/git/index.mjs +96 -0
- package/src/domain/git/resolveGerrit.mjs +102 -0
- package/src/domain/overtime/analyze.mjs +48 -0
- package/src/domain/overtime/index.mjs +3 -0
- package/src/domain/overtime/perPeriod.mjs +15 -0
- package/src/domain/overtime/render.mjs +15 -0
- package/src/i18n/index.mjs +38 -0
- package/src/i18n/resources.mjs +252 -0
- package/src/index.mjs +132 -649
- package/src/infra/cache.mjs +0 -0
- package/src/infra/configStore.mjs +128 -0
- package/src/infra/fs.mjs +0 -0
- package/src/infra/path.mjs +0 -0
- package/src/output/csv/overtime.mjs +12 -0
- package/src/output/csv.mjs +0 -0
- package/src/output/data/readData.mjs +54 -0
- package/src/output/data/writeData.mjs +145 -0
- package/src/output/excel/commits.mjs +9 -0
- package/src/output/excel/outputExcelDayReport.mjs +92 -0
- package/src/output/excel/perPeriod.mjs +24 -0
- package/src/{excel.mjs → output/excel.mjs} +3 -2
- package/src/output/index.mjs +79 -0
- package/src/output/json/overtime.mjs +9 -0
- package/src/output/tab/overtime.mjs +12 -0
- package/src/output/tab.mjs +0 -0
- package/src/output/text/commits.mjs +9 -0
- package/src/output/text/index.mjs +3 -0
- package/src/output/text/outputTxtDayReport.mjs +74 -0
- package/src/output/text/overtime.mjs +18 -0
- package/src/output/utils/getEsmJs.mjs +10 -0
- package/src/output/utils/index.mjs +14 -0
- package/src/output/utils/outputPath.mjs +19 -0
- package/src/output/utils/writeFile.mjs +10 -0
- package/src/serve/index.mjs +0 -0
- package/src/{server.mjs → serve/startServer.mjs} +21 -3
- package/src/serve/writeData.mjs +0 -0
- package/src/utils/authorNormalizer.mjs +28 -2
- package/src/utils/buildAuthorChangeStats.mjs +44 -0
- package/src/utils/deepMerge.mjs +13 -0
- package/src/utils/getPackage.mjs +11 -0
- package/src/utils/getProfileDirFile.mjs +12 -0
- package/src/utils/{file.mjs → groupRecords.mjs} +8 -9
- package/src/utils/index.mjs +5 -2
- package/src/utils/logger.mjs +28 -17
- package/src/utils/profiler.mjs +0 -101
- package/src/utils/resolve.mjs +11 -0
- package/src/utils/showVersionInfo.mjs +6 -2
- package/src/utils/time.mjs +0 -0
- package/src/utils/wait.mjs +2 -0
- package/web/app.js +3197 -257
- package/web/index.html +171 -22
- package/web/revoke/alpha1/app.js +4324 -0
- package/web/revoke/alpha1/index.html +266 -0
- package/web/revoke/app.before.js +3139 -0
- package/web/revoke/index-before.html +181 -0
- package/web/static/style.css +116 -9
- package/src/git.mjs +0 -256
- package/src/handlers/handleServe.mjs +0 -203
- package/src/lib/configStore.mjs +0 -11
- package/src/lib/memoize.mjs +0 -14
- package/src/utils/analyzeOvertimeCached.mjs +0 -7
- package/src/utils/checkUpdate.mjs +0 -130
- package/src/utils/exitWithTime.mjs +0 -17
- package/src/utils/handleSuccess.mjs +0 -9
- package/src/utils/logDev.mjs +0 -19
- package/src/utils/output.mjs +0 -26
- package/src/utils/profiler/diff.mjs +0 -26
- package/src/utils/profiler/format.mjs +0 -11
- package/src/utils/profiler/index.mjs +0 -144
- package/src/utils/profiler/trace.mjs +0 -26
- package/src/utils/time/scopeTimer.mjs +0 -37
- package/src/utils/time/timer.mjs +0 -33
- package/src/utils/time/withTimer.mjs +0 -11
- package/src/utils/timer.mjs +0 -35
- /package/src/{overtime → domain/overtime}/createOvertimeStats.mjs +0 -0
- /package/src/{overtime → domain/overtime}/overtime.mjs +0 -0
- /package/src/{json.mjs → output/json.mjs} +0 -0
- /package/src/{renderAuthorMapText.mjs → output/renderAuthorMapText.mjs} +0 -0
- /package/src/{stats-text.mjs → output/stats-text.mjs} +0 -0
- /package/src/{stats.mjs → output/stats.mjs} +0 -0
- /package/src/{text.mjs → output/text.mjs} +0 -0
package/web/app.js
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
/* eslint-disable import/no-absolute-path */
|
|
2
2
|
/* eslint-disable no-use-before-define */
|
|
3
|
+
/* eslint-disable no-restricted-globals */
|
|
3
4
|
/* global echarts */
|
|
5
|
+
|
|
6
|
+
// 1. 定义一个存储所有实例的数组
|
|
7
|
+
const chartInstances = []
|
|
4
8
|
const formatDate = (d) => new Date(d).toLocaleString()
|
|
5
9
|
|
|
10
|
+
// 综合判断函数,考虑多种情况
|
|
11
|
+
function isEmptyObject(obj) {
|
|
12
|
+
// 1. 检查是否为对象
|
|
13
|
+
if (obj === null || typeof obj !== 'object') {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 2. 检查是否是空对象
|
|
18
|
+
return Object.keys(obj).length === 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 根据对象内容隐藏对应图表卡片
|
|
22
|
+
function hideElementByObj({ el, objectName }) {
|
|
23
|
+
const isEmpty = isEmptyObject(objectName)
|
|
24
|
+
if (isEmpty) {
|
|
25
|
+
const chartCard = el?.closest('.chart-card')
|
|
26
|
+
chartCard.style.display = 'none'
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
return isEmpty
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hideElementByEl({ el }) {
|
|
33
|
+
if (!el) return true
|
|
34
|
+
const chartCard = el.closest('.chart-card')
|
|
35
|
+
if (chartCard) {
|
|
36
|
+
chartCard.style.display = 'none'
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
function filterByDate(commits) {
|
|
7
43
|
const start = document.getElementById('startDate')?.value
|
|
8
44
|
const end = document.getElementById('endDate')?.value
|
|
@@ -39,6 +75,38 @@ function formatDateYMD(d) {
|
|
|
39
75
|
return `${yyyy}-${mm}-${dd}`
|
|
40
76
|
}
|
|
41
77
|
|
|
78
|
+
// 将 CLI 传入或 RC 中的 author 筛选信息格式化为可读字符串
|
|
79
|
+
function formatAuthorFilter(a) {
|
|
80
|
+
if (!a) return null
|
|
81
|
+
|
|
82
|
+
function toList(v) {
|
|
83
|
+
if (!v) return null
|
|
84
|
+
if (Array.isArray(v)) return v.map((s) => String(s).trim()).filter(Boolean)
|
|
85
|
+
return String(v)
|
|
86
|
+
.split(',')
|
|
87
|
+
.map((s) => s.trim())
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (typeof a === 'string') {
|
|
92
|
+
const list = toList(a)
|
|
93
|
+
return list && list.length ? list.join(', ') : String(a)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (Array.isArray(a)) return a.join(', ')
|
|
97
|
+
|
|
98
|
+
if (typeof a === 'object') {
|
|
99
|
+
const include = toList(a.include)
|
|
100
|
+
const exclude = toList(a.exclude)
|
|
101
|
+
const parts = []
|
|
102
|
+
if (include && include.length) parts.push(`包含:${include.join(', ')}`)
|
|
103
|
+
if (exclude && exclude.length) parts.push(`排除:${exclude.join(', ')}`)
|
|
104
|
+
return parts.length ? parts.join(';') : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return String(a)
|
|
108
|
+
}
|
|
109
|
+
|
|
42
110
|
function getISOWeekRange(isoYear, isoWeek) {
|
|
43
111
|
// 找到 ISO 年的第一个周一
|
|
44
112
|
// ISO 年的第 1 周包含 1 月 4 日
|
|
@@ -61,44 +129,27 @@ function getISOWeekRange(isoYear, isoWeek) {
|
|
|
61
129
|
}
|
|
62
130
|
|
|
63
131
|
async function loadData() {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
authorChangesModule
|
|
73
|
-
] = await Promise.all([
|
|
74
|
-
import('/data/commits.mjs'),
|
|
75
|
-
import('/data/overtime-stats.mjs'),
|
|
76
|
-
import('/data/overtime-weekly.mjs'),
|
|
77
|
-
import('/data/overtime-monthly.mjs').catch(() => ({ default: [] })),
|
|
78
|
-
import('/data/overtime-latest-by-day.mjs').catch(() => ({ default: [] })),
|
|
79
|
-
import('/data/config.mjs').catch(() => ({ default: {} })),
|
|
80
|
-
import('/data/author-changes.mjs').catch(() => ({ default: {} }))
|
|
81
|
-
])
|
|
82
|
-
const commits = commitsModule.default || []
|
|
83
|
-
const stats = statsModule.default || {}
|
|
84
|
-
const weekly = weeklyModule.default || []
|
|
85
|
-
const monthly = monthlyModule.default || []
|
|
86
|
-
const latestByDay = latestByDayModule.default || []
|
|
87
|
-
const config = configModule.default || {}
|
|
88
|
-
const authorChanges = authorChangesModule.default || {}
|
|
89
|
-
return {
|
|
90
|
-
commits,
|
|
91
|
-
stats,
|
|
92
|
-
weekly,
|
|
93
|
-
monthly,
|
|
94
|
-
latestByDay,
|
|
95
|
-
config,
|
|
96
|
-
authorChanges
|
|
132
|
+
// 定义加载函数,包装 import 以便添加错误处理
|
|
133
|
+
const safeImport = async (path, defaultValue) => {
|
|
134
|
+
try {
|
|
135
|
+
const module = await import(path)
|
|
136
|
+
return module.default || defaultValue
|
|
137
|
+
} catch (e) {
|
|
138
|
+
console.warn(`文件加载失败: ${path}`, e)
|
|
139
|
+
return defaultValue
|
|
97
140
|
}
|
|
98
|
-
} catch (err) {
|
|
99
|
-
console.error('Load data failed', err)
|
|
100
|
-
return { commits: [], stats: {}, weekly: [], monthly: [], latestByDay: [] }
|
|
101
141
|
}
|
|
142
|
+
|
|
143
|
+
// 并行加载基础数据(只加载快速的 analyze 生成的文件)
|
|
144
|
+
// 移除 overtime 文件加载,改为前端实时计算
|
|
145
|
+
const [commits, config, authorChanges, options] = await Promise.all([
|
|
146
|
+
safeImport('/data/commits.mjs', []),
|
|
147
|
+
safeImport('/data/config.mjs', {}),
|
|
148
|
+
safeImport('/data/author.changes.mjs', {}),
|
|
149
|
+
safeImport('/data/options.mjs', {})
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
return { commits, config, authorChanges, options }
|
|
102
153
|
}
|
|
103
154
|
|
|
104
155
|
let commitsAll = []
|
|
@@ -111,9 +162,13 @@ function renderCommitsTablePage() {
|
|
|
111
162
|
tbody.innerHTML = ''
|
|
112
163
|
const start = (page - 1) * pageSize
|
|
113
164
|
const end = start + pageSize
|
|
165
|
+
if(!window.__config.git.numstat) {
|
|
166
|
+
document.getElementById('thChanged').style.display = 'none'
|
|
167
|
+
}
|
|
114
168
|
filtered.slice(start, end).forEach((c) => {
|
|
115
169
|
const tr = document.createElement('tr')
|
|
116
|
-
|
|
170
|
+
const changedTd = window.__config.git.numstat ? `<td>${c.changed}</td>` : ''
|
|
171
|
+
tr.innerHTML = `<td class="hash">${c.hash.slice(0, 8)}</td><td>${c.author}</td><td class="email">${c.email}</td><td><div class="date">${formatDate(c.date)}</div></td><td>${c.message}</td><td class="cherryPick">${c.isCherryPick}</td>${changedTd}`
|
|
117
172
|
tbody.appendChild(tr)
|
|
118
173
|
})
|
|
119
174
|
document.getElementById('commitsTotal').textContent =
|
|
@@ -192,28 +247,61 @@ function initTableControls() {
|
|
|
192
247
|
|
|
193
248
|
function drawHourlyOvertime(stats, onHourClick) {
|
|
194
249
|
const el = document.getElementById('hourlyOvertimeChart')
|
|
250
|
+
|
|
251
|
+
const isEmpty = hideElementByObj({ el, objectName: stats })
|
|
252
|
+
if (isEmpty) {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
195
255
|
const chart = echarts.init(el)
|
|
256
|
+
chartInstances.push(chart)
|
|
196
257
|
|
|
197
|
-
|
|
198
|
-
const
|
|
258
|
+
// 显示所有提交数(不仅仅是加班)
|
|
259
|
+
const allCommits = Array(24).fill(0)
|
|
199
260
|
const labels = Array.from({ length: 24 }, (_, i) =>
|
|
200
261
|
String(i).padStart(2, '0')
|
|
201
262
|
)
|
|
202
263
|
|
|
203
|
-
//
|
|
264
|
+
// 从原始 commits 数据重新计算每小时的所有提交数
|
|
265
|
+
// 如果没有则使用后备逻辑
|
|
266
|
+
if (window.__allCommitsData && Array.isArray(window.__allCommitsData)) {
|
|
267
|
+
window.__allCommitsData.forEach((c) => {
|
|
268
|
+
const d = new Date(c.date)
|
|
269
|
+
if (!isNaN(d.getTime())) {
|
|
270
|
+
const h = d.getHours()
|
|
271
|
+
allCommits[h]++
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 颜色逻辑:根据时间段着色
|
|
204
277
|
function getColor(h) {
|
|
205
|
-
|
|
206
|
-
if (h
|
|
207
|
-
|
|
208
|
-
if (h >= stats.startHour && h < stats.
|
|
209
|
-
|
|
278
|
+
// 深夜(0-9 点)红色
|
|
279
|
+
if (h < stats.startHour) return '#b71c1c'
|
|
280
|
+
// 上班开始到午休开始:蓝色
|
|
281
|
+
if (h >= stats.startHour && h < stats.lunchStart) return '#1976d2'
|
|
282
|
+
// 午休时间:灰色
|
|
283
|
+
if (h >= stats.lunchStart && h < stats.lunchEnd) return '#888888'
|
|
284
|
+
// 午休结束到下班:蓝色
|
|
285
|
+
if (h >= stats.lunchEnd && h < stats.endHour) return '#1976d2'
|
|
286
|
+
// 下班后到晚上 19:00:橙色
|
|
287
|
+
if (h >= stats.endHour && h < 19) return '#fb8c00'
|
|
288
|
+
// 晚上 19:00 到深夜 21:00:深橙
|
|
289
|
+
if (h >= 19 && h < 21) return '#fb8c00'
|
|
290
|
+
// 深夜 21:00 后:红色
|
|
291
|
+
return '#d32f2f'
|
|
210
292
|
}
|
|
211
293
|
|
|
212
|
-
const data =
|
|
294
|
+
const data = allCommits.map((v, h) => ({
|
|
213
295
|
value: v,
|
|
214
296
|
itemStyle: { color: getColor(h) }
|
|
215
297
|
}))
|
|
216
298
|
|
|
299
|
+
// 计算百分比
|
|
300
|
+
const total = allCommits.reduce((sum, v) => sum + v, 0)
|
|
301
|
+
const percentData = allCommits.map((v) =>
|
|
302
|
+
total > 0 ? ((v / total) * 100).toFixed(1) : 0
|
|
303
|
+
)
|
|
304
|
+
|
|
217
305
|
chart.setOption({
|
|
218
306
|
tooltip: {
|
|
219
307
|
trigger: 'axis',
|
|
@@ -221,11 +309,31 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
221
309
|
const p = params[0]
|
|
222
310
|
const h = parseInt(p.axisValue, 10)
|
|
223
311
|
const count = p.value
|
|
224
|
-
const
|
|
312
|
+
const percent = percentData[h]
|
|
313
|
+
|
|
314
|
+
// 判断时间段
|
|
315
|
+
let period = ''
|
|
316
|
+
if (h < stats.startHour) {
|
|
317
|
+
period = '深夜(需要休息)'
|
|
318
|
+
} else if (h >= stats.startHour && h < stats.lunchStart) {
|
|
319
|
+
period = '早上工作时间'
|
|
320
|
+
} else if (h >= stats.lunchStart && h < stats.lunchEnd) {
|
|
321
|
+
period = '午休时间'
|
|
322
|
+
} else if (h >= stats.lunchEnd && h < stats.endHour) {
|
|
323
|
+
period = '下午工作时间'
|
|
324
|
+
} else if (h >= stats.endHour && h < 19) {
|
|
325
|
+
period = '下班后(轻度加班)'
|
|
326
|
+
} else if (h >= 19 && h < 21) {
|
|
327
|
+
period = '晚间(中度加班)'
|
|
328
|
+
} else {
|
|
329
|
+
period = '深夜(严重加班)'
|
|
330
|
+
}
|
|
331
|
+
|
|
225
332
|
return `
|
|
226
333
|
🕒 <b>${h}:00</b><br/>
|
|
227
334
|
提交次数:<b>${count}</b><br/>
|
|
228
|
-
占全天比例:<b>${
|
|
335
|
+
占全天比例:<b>${percent}%</b><br/>
|
|
336
|
+
时段:${period}
|
|
229
337
|
`
|
|
230
338
|
}
|
|
231
339
|
},
|
|
@@ -247,7 +355,7 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
247
355
|
series: [
|
|
248
356
|
{
|
|
249
357
|
type: 'bar',
|
|
250
|
-
name: '
|
|
358
|
+
name: '每小时提交',
|
|
251
359
|
data,
|
|
252
360
|
barWidth: 18,
|
|
253
361
|
|
|
@@ -260,7 +368,7 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
260
368
|
name: '最晚提交',
|
|
261
369
|
coord: [
|
|
262
370
|
String(stats.latestCommitHour).padStart(2, '0'),
|
|
263
|
-
|
|
371
|
+
allCommits[stats.latestCommitHour] || 0
|
|
264
372
|
]
|
|
265
373
|
}
|
|
266
374
|
]
|
|
@@ -307,14 +415,21 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
307
415
|
}
|
|
308
416
|
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
309
417
|
if (Number.isNaN(hour)) return
|
|
310
|
-
|
|
418
|
+
|
|
419
|
+
// 获取该小时的所有提交
|
|
420
|
+
const hourCommits = (window.__allCommitsData || []).filter((c) => {
|
|
421
|
+
const d = new Date(c.date)
|
|
422
|
+
return !isNaN(d.getTime()) && d.getHours() === hour
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
onHourClick(hour, hourCommits)
|
|
311
426
|
})
|
|
312
427
|
}
|
|
313
428
|
|
|
314
429
|
return chart
|
|
315
430
|
}
|
|
316
431
|
|
|
317
|
-
//
|
|
432
|
+
// 每小时加班分布
|
|
318
433
|
function showSideBarForHour({ hour, commitsOrCount, titleDrawer }) {
|
|
319
434
|
// 支持传入 number(仅次数)或 array(详细 commit 列表)
|
|
320
435
|
// 统一复用通用详情侧栏 DOM
|
|
@@ -344,14 +459,14 @@ function showSideBarForHour({ hour, commitsOrCount, titleDrawer }) {
|
|
|
344
459
|
// commits 列表:展示作者/时间/消息(最多前 50 条,避免性能问题)
|
|
345
460
|
const commits = commitsOrCount.slice(0, 50)
|
|
346
461
|
contentEl.innerHTML = `<div class="sidebar-list">${commits
|
|
347
|
-
.map((c) => {
|
|
462
|
+
.map((c, index) => {
|
|
348
463
|
const author = c.author ?? c.name ?? 'unknown'
|
|
349
464
|
const time = c.date ?? c.time ?? ''
|
|
350
465
|
const msg = (c.message ?? c.msg ?? c.body ?? '').replace(/\n/g, ' ')
|
|
351
466
|
return `
|
|
352
467
|
<div class="sidebar-item">
|
|
353
468
|
<div class="sidebar-item-header">
|
|
354
|
-
<span class="author"
|
|
469
|
+
<span class="author">${index + 1}. 👤 ${escapeHtml(author)}</span>
|
|
355
470
|
<span class="time">🕒 ${escapeHtml(time)}</span>
|
|
356
471
|
</div>
|
|
357
472
|
<div class="sidebar-item-message">${escapeHtml(msg)}</div>
|
|
@@ -383,30 +498,121 @@ function escapeHtml(str = '') {
|
|
|
383
498
|
.replaceAll("'", ''')
|
|
384
499
|
}
|
|
385
500
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
501
|
+
/**
|
|
502
|
+
* 通用环形饼图(中心显示总数)
|
|
503
|
+
*
|
|
504
|
+
* @param {Object} options
|
|
505
|
+
* @param {string|HTMLElement} options.el DOM 或 id
|
|
506
|
+
* @param {Array} options.data [{ name, value }]
|
|
507
|
+
* @param {string} [options.title] 标题
|
|
508
|
+
* @param {string} [options.unit='次'] 单位
|
|
509
|
+
* @param {Array} [options.colors] 自定义颜色
|
|
510
|
+
* @param {string} [options.totalLabel='总计'] 中心文案
|
|
511
|
+
*/
|
|
512
|
+
export function drawPieWithTotal({
|
|
513
|
+
el,
|
|
514
|
+
data = [],
|
|
515
|
+
title = '',
|
|
516
|
+
unit = '次',
|
|
517
|
+
colors = [],
|
|
518
|
+
totalLabel = '总计'
|
|
519
|
+
}) {
|
|
520
|
+
const dom = typeof el === 'string' ? document.getElementById(el) : el
|
|
521
|
+
const chart = echarts.init(dom)
|
|
522
|
+
chartInstances.push(chart)
|
|
523
|
+
|
|
524
|
+
let total = data.reduce((sum, item) => sum + (item.value || 0), 0)
|
|
525
|
+
total = Math.round(total)
|
|
526
|
+
// FIXME: remove debug log before production
|
|
527
|
+
console.log('❌', 'total', total)
|
|
528
|
+
const safeData = total === 0 ? [{ name: '暂无数据', value: 1 }] : data
|
|
529
|
+
|
|
393
530
|
chart.setOption({
|
|
394
|
-
|
|
531
|
+
color: colors.length ? colors : undefined,
|
|
532
|
+
|
|
533
|
+
title: title
|
|
534
|
+
? {
|
|
535
|
+
text: title,
|
|
536
|
+
left: 'center',
|
|
537
|
+
top: 10
|
|
538
|
+
}
|
|
539
|
+
: undefined,
|
|
540
|
+
|
|
541
|
+
tooltip: {
|
|
542
|
+
trigger: 'item',
|
|
543
|
+
formatter: (params) => {
|
|
544
|
+
if (total === 0) return '暂无数据'
|
|
545
|
+
return `
|
|
546
|
+
${params.name}<br/>
|
|
547
|
+
数量:${params.value} ${unit}<br/>
|
|
548
|
+
占比:${params.percent}%
|
|
549
|
+
`
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
legend: {
|
|
553
|
+
bottom: 0,
|
|
554
|
+
formatter: (name) => `${name}`
|
|
555
|
+
},
|
|
556
|
+
graphic:
|
|
557
|
+
total === 0
|
|
558
|
+
? []
|
|
559
|
+
: [
|
|
560
|
+
{
|
|
561
|
+
type: 'text',
|
|
562
|
+
left: 'center',
|
|
563
|
+
top: '45%',
|
|
564
|
+
style: {
|
|
565
|
+
text: `${totalLabel}\n${total} ${unit}`,
|
|
566
|
+
textAlign: 'center',
|
|
567
|
+
fill: '#333',
|
|
568
|
+
fontSize: 14,
|
|
569
|
+
fontWeight: 600
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
],
|
|
573
|
+
|
|
395
574
|
series: [
|
|
396
575
|
{
|
|
397
576
|
type: 'pie',
|
|
398
|
-
radius: '
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
577
|
+
radius: ['40%', '60%'],
|
|
578
|
+
avoidLabelOverlap: false,
|
|
579
|
+
label: {
|
|
580
|
+
show: total !== 0,
|
|
581
|
+
formatter: `{b}\n{c} ${unit}`
|
|
582
|
+
},
|
|
583
|
+
emphasis: {
|
|
584
|
+
scale: true,
|
|
585
|
+
scaleSize: 8
|
|
586
|
+
},
|
|
587
|
+
data: safeData
|
|
403
588
|
}
|
|
404
589
|
]
|
|
405
590
|
})
|
|
591
|
+
|
|
406
592
|
return chart
|
|
407
593
|
}
|
|
408
594
|
|
|
595
|
+
function drawOutsideVsInside(stats) {
|
|
596
|
+
const outside = stats.outsideWorkCount || 0
|
|
597
|
+
const total = stats.total || 0
|
|
598
|
+
const inside = Math.max(0, total - outside)
|
|
599
|
+
|
|
600
|
+
return drawPieWithTotal({
|
|
601
|
+
el: 'outsideVsInsideChart',
|
|
602
|
+
title: '提交时间分布',
|
|
603
|
+
unit: '次',
|
|
604
|
+
totalLabel: '总提交',
|
|
605
|
+
data: [
|
|
606
|
+
{ name: '工作时间内', value: inside },
|
|
607
|
+
{ name: '下班时间', value: outside }
|
|
608
|
+
],
|
|
609
|
+
colors: ['#5470C6', '#EE6666']
|
|
610
|
+
})
|
|
611
|
+
}
|
|
612
|
+
|
|
409
613
|
function drawDailyTrend(commits, onDayClick) {
|
|
614
|
+
const el = document.getElementById('dailyTrendChart')
|
|
615
|
+
|
|
410
616
|
if (!Array.isArray(commits) || commits.length === 0) return null
|
|
411
617
|
|
|
412
618
|
// 聚合每日提交数量
|
|
@@ -419,11 +625,11 @@ function drawDailyTrend(commits, onDayClick) {
|
|
|
419
625
|
const labels = Array.from(map.keys()).sort()
|
|
420
626
|
const data = labels.map((l) => map.get(l))
|
|
421
627
|
|
|
422
|
-
const el = document.getElementById('dailyTrendChart')
|
|
423
628
|
const titleDrawer = el.getAttribute('data-title') || ''
|
|
424
629
|
|
|
425
630
|
// eslint-disable-next-line no-undef
|
|
426
631
|
const chart = echarts.init(el)
|
|
632
|
+
chartInstances.push(chart)
|
|
427
633
|
|
|
428
634
|
chart.setOption({
|
|
429
635
|
tooltip: {
|
|
@@ -550,14 +756,14 @@ function showSideBarForWeek({ period, weeklyItem, commits = [], titleDrawer }) {
|
|
|
550
756
|
html += `<div style="padding:10px;color:#777;">该周无提交记录</div>`
|
|
551
757
|
} else {
|
|
552
758
|
html += `<div class="sidebar-list">${commits
|
|
553
|
-
.map((c) => {
|
|
759
|
+
.map((c, index) => {
|
|
554
760
|
const author = escapeHtml(c.author || 'unknown')
|
|
555
761
|
const time = escapeHtml(c.date || '')
|
|
556
762
|
const msg = escapeHtml((c.message || '').replace(/\n/g, ' '))
|
|
557
763
|
return `
|
|
558
764
|
<div class="sidebar-item">
|
|
559
765
|
<div class="sidebar-item-header">
|
|
560
|
-
<span class="author"
|
|
766
|
+
<span class="author">${index + 1}👤 ${author}</span>
|
|
561
767
|
<span class="time">🕒 ${time}</span>
|
|
562
768
|
</div>
|
|
563
769
|
<div class="sidebar-item-message">${msg}</div>
|
|
@@ -573,16 +779,23 @@ function showSideBarForWeek({ period, weeklyItem, commits = [], titleDrawer }) {
|
|
|
573
779
|
}
|
|
574
780
|
|
|
575
781
|
function drawWeeklyTrend(weekly, commits, onWeekClick) {
|
|
576
|
-
|
|
782
|
+
const el = document.getElementById('weeklyTrendChart')
|
|
783
|
+
const isEmpty = hideElementByObj({ el, objectName: weekly })
|
|
784
|
+
if (isEmpty) {
|
|
785
|
+
return null
|
|
786
|
+
}
|
|
787
|
+
if (!Array.isArray(weekly) || weekly.length === 0) {
|
|
788
|
+
return null
|
|
789
|
+
}
|
|
577
790
|
|
|
578
791
|
const labels = weekly.map((w) => w.period)
|
|
579
792
|
const dataRate = weekly.map((w) => +(w.outsideWorkRate * 100).toFixed(1)) // %
|
|
580
793
|
const dataCount = weekly.map((w) => w.outsideWorkCount)
|
|
581
794
|
|
|
582
|
-
const el = document.getElementById('weeklyTrendChart')
|
|
583
795
|
const titleDrawer = el.getAttribute('data-title') || ''
|
|
584
796
|
|
|
585
797
|
const chart = echarts.init(el)
|
|
798
|
+
chartInstances.push(chart)
|
|
586
799
|
|
|
587
800
|
chart.setOption({
|
|
588
801
|
tooltip: {
|
|
@@ -698,15 +911,20 @@ function drawWeeklyTrend(weekly, commits, onWeekClick) {
|
|
|
698
911
|
}
|
|
699
912
|
|
|
700
913
|
function drawMonthlyTrend(monthly, commits, onMonthClick) {
|
|
914
|
+
const el = document.getElementById('monthlyTrendChart')
|
|
915
|
+
const isEmpty = hideElementByObj({ el, objectName: monthly })
|
|
916
|
+
if (isEmpty) {
|
|
917
|
+
return null
|
|
918
|
+
}
|
|
701
919
|
if (!Array.isArray(monthly) || monthly.length === 0) return null
|
|
702
920
|
|
|
703
921
|
const labels = monthly.map((m) => m.period)
|
|
704
922
|
const dataRate = monthly.map((m) => +(m.outsideWorkRate * 100).toFixed(1)) // 0–100%
|
|
705
923
|
|
|
706
|
-
const el = document.getElementById('monthlyTrendChart')
|
|
707
924
|
const titleDrawer = el.getAttribute('data-title') || ''
|
|
708
925
|
// eslint-disable-next-line no-undef
|
|
709
926
|
const chart = echarts.init(el)
|
|
927
|
+
chartInstances.push(chart)
|
|
710
928
|
|
|
711
929
|
chart.setOption({
|
|
712
930
|
tooltip: {
|
|
@@ -821,6 +1039,11 @@ function drawMonthlyTrend(monthly, commits, onMonthClick) {
|
|
|
821
1039
|
}
|
|
822
1040
|
|
|
823
1041
|
function drawLatestHourDaily(latestByDay, commits, onDayClick) {
|
|
1042
|
+
const el = document.getElementById('latestHourDailyChart')
|
|
1043
|
+
const isEmpty = hideElementByObj({ el, objectName: latestByDay })
|
|
1044
|
+
if (isEmpty) {
|
|
1045
|
+
return null
|
|
1046
|
+
}
|
|
824
1047
|
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
|
|
825
1048
|
|
|
826
1049
|
const labels = latestByDay.map((d) => d.date)
|
|
@@ -849,11 +1072,11 @@ function drawLatestHourDaily(latestByDay, commits, onDayClick) {
|
|
|
849
1072
|
const numericValues = raw.filter((v) => typeof v === 'number')
|
|
850
1073
|
const maxV = numericValues.length > 0 ? Math.max(...numericValues) : 0
|
|
851
1074
|
|
|
852
|
-
const el = document.getElementById('latestHourDailyChart')
|
|
853
1075
|
const titleDrawer = el.getAttribute('data-title') || ''
|
|
854
1076
|
|
|
855
1077
|
// eslint-disable-next-line no-undef
|
|
856
1078
|
const chart = echarts.init(el)
|
|
1079
|
+
chartInstances.push(chart)
|
|
857
1080
|
|
|
858
1081
|
chart.setOption({
|
|
859
1082
|
tooltip: {
|
|
@@ -965,6 +1188,11 @@ function drawLatestHourDaily(latestByDay, commits, onDayClick) {
|
|
|
965
1188
|
}
|
|
966
1189
|
|
|
967
1190
|
function drawDailySeverity(latestByDay, commits, onDayClick) {
|
|
1191
|
+
const el = document.getElementById('dailySeverityChart')
|
|
1192
|
+
const isEmpty = hideElementByObj({ el, objectName: latestByDay })
|
|
1193
|
+
if (isEmpty) {
|
|
1194
|
+
return null
|
|
1195
|
+
}
|
|
968
1196
|
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
|
|
969
1197
|
|
|
970
1198
|
const labels = latestByDay.map((d) => d.date)
|
|
@@ -980,11 +1208,11 @@ function drawDailySeverity(latestByDay, commits, onDayClick) {
|
|
|
980
1208
|
// 这里按 0 小时加班处理,保证折线连续。
|
|
981
1209
|
const sev = raw.map((v) => (v == null ? 0 : Math.max(0, Number(v) - endH)))
|
|
982
1210
|
|
|
983
|
-
const el = document.getElementById('dailySeverityChart')
|
|
984
1211
|
const titleDrawer = el.getAttribute('data-title') || ''
|
|
985
1212
|
|
|
986
1213
|
// eslint-disable-next-line no-undef
|
|
987
1214
|
const chart = echarts.init(el)
|
|
1215
|
+
chartInstances.push(chart)
|
|
988
1216
|
|
|
989
1217
|
chart.setOption({
|
|
990
1218
|
tooltip: {
|
|
@@ -1138,7 +1366,7 @@ function drawDailyTrendSeverity(commits, weekly, onDayClick) {
|
|
|
1138
1366
|
|
|
1139
1367
|
// ---------- 3. 自动分析「最累的一周」 ----------
|
|
1140
1368
|
let maxWeek = null
|
|
1141
|
-
if (Array.isArray(weekly)) {
|
|
1369
|
+
if (Array.isArray(weekly) && weekly.length > 0) {
|
|
1142
1370
|
maxWeek = weekly.reduce((a, b) =>
|
|
1143
1371
|
a.outsideWorkCount > b.outsideWorkCount ? a : b
|
|
1144
1372
|
)
|
|
@@ -1186,24 +1414,26 @@ function drawDailyTrendSeverity(commits, weekly, onDayClick) {
|
|
|
1186
1414
|
const count = params?.[0].value
|
|
1187
1415
|
const details = dayCommitsDetail[date] || []
|
|
1188
1416
|
|
|
1189
|
-
let html = `📅 <b>${date}</b><br/>提交次数:${count}<br
|
|
1417
|
+
let html = `📅 <b>${date}</b><br/>提交次数:${count}<br/>`
|
|
1190
1418
|
|
|
1191
1419
|
details.slice(0, 5).forEach((d) => {
|
|
1192
|
-
html += `👤 ${d.author}<br/>🕒 ${d.time}<br
|
|
1420
|
+
html += `👤 ${d.author}<br/>🕒 ${d.time}<br/> <div class="long-txt-break-all">💬${d.msg}</div>`
|
|
1193
1421
|
})
|
|
1194
1422
|
|
|
1195
1423
|
if (details.length > 5) {
|
|
1196
1424
|
html += `(其余 ${details.length - 5} 条已省略)`
|
|
1197
1425
|
}
|
|
1198
|
-
|
|
1426
|
+
html = `<div class="tooltip-box">${html}</div>`
|
|
1199
1427
|
return html
|
|
1200
1428
|
}
|
|
1201
1429
|
|
|
1202
1430
|
// ---------- 7. 绘图 ----------
|
|
1203
1431
|
const el = document.getElementById('dailyTrendChartDog')
|
|
1432
|
+
|
|
1204
1433
|
const titleDrawer = el.getAttribute('data-title') || ''
|
|
1205
1434
|
|
|
1206
1435
|
const chart = echarts.init(el)
|
|
1436
|
+
chartInstances.push(chart)
|
|
1207
1437
|
|
|
1208
1438
|
chart.setOption({
|
|
1209
1439
|
tooltip: {
|
|
@@ -1276,10 +1506,10 @@ function showDayDetailSidebar({ date, count, commits, titleDrawer }) {
|
|
|
1276
1506
|
// 渲染详情
|
|
1277
1507
|
content.innerHTML = commits
|
|
1278
1508
|
.map(
|
|
1279
|
-
(c) => `
|
|
1509
|
+
(c, index) => `
|
|
1280
1510
|
<div class="sidebar-item">
|
|
1281
1511
|
<div class="sidebar-item-header">
|
|
1282
|
-
<span class="author"
|
|
1512
|
+
<span class="author">${index + 1}👤 ${escapeHtml(c.author || 'unknown')}</span>
|
|
1283
1513
|
<span class="time">🕒 ${escapeHtml(c.time || c.date || '')}</span>
|
|
1284
1514
|
</div>
|
|
1285
1515
|
<div class="sidebar-item-message">${escapeHtml(c.msg || c.message || '')}</div>
|
|
@@ -1345,14 +1575,59 @@ function renderKpi(stats) {
|
|
|
1345
1575
|
(latestOut ? new Date(latestOut.date).getHours() : null)
|
|
1346
1576
|
}
|
|
1347
1577
|
|
|
1578
|
+
const htmlLatest = latest
|
|
1579
|
+
? `<div>最后一次提交时间:${latest ? formatDate(latest.date) : '-'}${typeof latestHour === 'number' ? `(${String(latestHour).padStart(2, '0')}:00)` : ''} <div class="author">${latest?.author}</div> <div class="long-txt"> ${latest?.message} </div></div>`
|
|
1580
|
+
: ``
|
|
1581
|
+
|
|
1582
|
+
// 采样区间展示(来自 config 或 serve 参数),同时支持筛选条件(author)
|
|
1583
|
+
const samplingSince = window.__samplingSince || null
|
|
1584
|
+
const samplingUntil = window.__samplingUntil || null
|
|
1585
|
+
const samplingAuthor = window.__samplingAuthor || null
|
|
1586
|
+
function formatSampling(dStr) {
|
|
1587
|
+
if (!dStr) return null
|
|
1588
|
+
const d = new Date(dStr)
|
|
1589
|
+
if (Number.isNaN(d.valueOf())) return escapeHtml(dStr)
|
|
1590
|
+
return formatDateYMD(d)
|
|
1591
|
+
}
|
|
1592
|
+
let samplingHtml = ''
|
|
1593
|
+
if (samplingSince && samplingUntil) {
|
|
1594
|
+
samplingHtml = `<div class="hr"></div><div class="sampling">采样区间:${formatSampling(samplingSince)} ~ ${formatSampling(samplingUntil)}${samplingAuthor ? ` (作者 ${escapeHtml(samplingAuthor)})` : ''}</div>`
|
|
1595
|
+
} else if (samplingSince) {
|
|
1596
|
+
samplingHtml = `<div class="hr"></div><div class="sampling">采样起始:${formatSampling(samplingSince)}(起)${samplingAuthor ? ` (作者 ${escapeHtml(samplingAuthor)})` : ''}</div>`
|
|
1597
|
+
} else if (samplingUntil) {
|
|
1598
|
+
samplingHtml = `<div class="hr"></div><div class="sampling">采样截止:${formatSampling(samplingUntil)}(止)${samplingAuthor ? ` (作者 ${escapeHtml(samplingAuthor)})` : ''}</div>`
|
|
1599
|
+
} else {
|
|
1600
|
+
samplingHtml = `<div class="hr"></div><div class="sampling">采样区间:全量提交${samplingAuthor ? ` (作者 ${escapeHtml(samplingAuthor)})` : ''}</div>`
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1348
1603
|
const html = [
|
|
1349
|
-
|
|
1604
|
+
htmlLatest,
|
|
1350
1605
|
`<div class="hr"></div>`,
|
|
1351
|
-
`<div>加班最晚一次提交时间:${latestOut ? formatDate(latestOut.date) : '-'}${typeof latestOutHour === 'number' ? `(${String(latestOutHour).padStart(2, '0')}:00)` : ''} <div class="author">${latestOut
|
|
1606
|
+
`<div>加班最晚一次提交时间:${latestOut ? formatDate(latestOut.date) : '-'}${typeof latestOutHour === 'number' ? `(${String(latestOutHour).padStart(2, '0')}:00)` : ''} <div class="author">${latestOut?.author || ''}</div> <div class="long-txt">${latestOut?.message || ''}</div> </div>`,
|
|
1352
1607
|
`<div class="hr"></div>`,
|
|
1353
|
-
`<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div
|
|
1608
|
+
`<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div>`,
|
|
1609
|
+
samplingHtml
|
|
1354
1610
|
].join('')
|
|
1355
1611
|
el.innerHTML = html
|
|
1612
|
+
|
|
1613
|
+
// 同步显示在 header 侧边的采样信息(更醒目)
|
|
1614
|
+
const headerEl = document.getElementById('samplingInfo')
|
|
1615
|
+
if (headerEl) {
|
|
1616
|
+
const sSince = formatSampling(samplingSince)
|
|
1617
|
+
const sUntil = formatSampling(samplingUntil)
|
|
1618
|
+
const sAuthor = samplingAuthor
|
|
1619
|
+
? ` (作者 ${escapeHtml(samplingAuthor)})`
|
|
1620
|
+
: ''
|
|
1621
|
+
const sText =
|
|
1622
|
+
sSince && sUntil
|
|
1623
|
+
? `采样:${sSince} ~ ${sUntil}`
|
|
1624
|
+
: sSince
|
|
1625
|
+
? `采样起:${sSince}`
|
|
1626
|
+
: sUntil
|
|
1627
|
+
? `采样止:${sUntil}`
|
|
1628
|
+
: '采样:全量提交'
|
|
1629
|
+
headerEl.textContent = sText + sAuthor
|
|
1630
|
+
}
|
|
1356
1631
|
}
|
|
1357
1632
|
|
|
1358
1633
|
// 1) 按小时分组(例:commits 为原始提交数组)
|
|
@@ -1478,8 +1753,15 @@ function buildDataset(stats, type) {
|
|
|
1478
1753
|
|
|
1479
1754
|
const drawChangeTrends = (stats) => {
|
|
1480
1755
|
const el = document.getElementById('chartAuthorChanges')
|
|
1756
|
+
// FIXME: remove debug log before production
|
|
1757
|
+
console.log('❌', 'window.__config', window.__config)
|
|
1758
|
+
if (!window.__config.git.numstat) {
|
|
1759
|
+
hideElementByEl({ el })
|
|
1760
|
+
return null
|
|
1761
|
+
}
|
|
1481
1762
|
if (!el) return null
|
|
1482
1763
|
const chart = echarts.init(el)
|
|
1764
|
+
chartInstances.push(chart)
|
|
1483
1765
|
|
|
1484
1766
|
function render(type) {
|
|
1485
1767
|
const { authors, allPeriods, series } = buildDataset(stats, type)
|
|
@@ -1509,12 +1791,16 @@ const drawChangeTrends = (stats) => {
|
|
|
1509
1791
|
// extra = `<div style="margin-top:4px;color:#999;font-size:12px">
|
|
1510
1792
|
// 周区间:${start} ~ ${end}
|
|
1511
1793
|
// </div>`
|
|
1512
|
-
// TODO: remove debug log before production
|
|
1513
1794
|
extra = ''
|
|
1514
1795
|
}
|
|
1515
1796
|
|
|
1516
1797
|
const lines = params
|
|
1517
1798
|
.filter((i) => i.data > 0)
|
|
1799
|
+
.sort(
|
|
1800
|
+
(a, b) =>
|
|
1801
|
+
(b.data || 0) - (a.data || 0) ||
|
|
1802
|
+
String(a.seriesName).localeCompare(String(b.seriesName))
|
|
1803
|
+
)
|
|
1518
1804
|
.map(
|
|
1519
1805
|
(item) => `${item.marker}${item.seriesName}: ${item.data} 行变更`
|
|
1520
1806
|
)
|
|
@@ -1547,6 +1833,61 @@ const drawChangeTrends = (stats) => {
|
|
|
1547
1833
|
})
|
|
1548
1834
|
})
|
|
1549
1835
|
|
|
1836
|
+
// 点击事件:点击某个作者在某个周期的点,打开侧栏显示该作者在该周期的 commits
|
|
1837
|
+
chart.on('click', (p) => {
|
|
1838
|
+
try {
|
|
1839
|
+
if (!p || p.componentType !== 'series') return
|
|
1840
|
+
const label = p.axisValue || p.name
|
|
1841
|
+
const author = p.seriesName
|
|
1842
|
+
if (!label || !author) return
|
|
1843
|
+
const type =
|
|
1844
|
+
document.querySelector('#tabs button.active')?.dataset.type || 'daily'
|
|
1845
|
+
|
|
1846
|
+
const filteredCommits = (
|
|
1847
|
+
Array.isArray(commitsAll) ? commitsAll : []
|
|
1848
|
+
).filter((c) => {
|
|
1849
|
+
const a = c.author || 'unknown'
|
|
1850
|
+
if (a !== author) return false
|
|
1851
|
+
const d = new Date(c.date)
|
|
1852
|
+
if (Number.isNaN(d.valueOf())) return false
|
|
1853
|
+
if (type === 'daily') return d.toISOString().slice(0, 10) === label
|
|
1854
|
+
if (type === 'weekly') {
|
|
1855
|
+
if (!label.includes('-W')) return false
|
|
1856
|
+
const [yy, ww] = label.split('-W')
|
|
1857
|
+
const range = getISOWeekRange(Number(yy), Number(ww))
|
|
1858
|
+
const day = d.toISOString().slice(0, 10)
|
|
1859
|
+
return day >= range.start && day <= range.end
|
|
1860
|
+
}
|
|
1861
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
1862
|
+
return month === label
|
|
1863
|
+
})
|
|
1864
|
+
|
|
1865
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
1866
|
+
|
|
1867
|
+
if (type === 'weekly') {
|
|
1868
|
+
const weeklyItem = {
|
|
1869
|
+
outsideWorkCount: filteredCommits.length,
|
|
1870
|
+
outsideWorkRate: 0
|
|
1871
|
+
}
|
|
1872
|
+
showSideBarForWeek({
|
|
1873
|
+
period: label,
|
|
1874
|
+
weeklyItem,
|
|
1875
|
+
commits: filteredCommits,
|
|
1876
|
+
titleDrawer: `${author} 变更量 ${type} 详情`
|
|
1877
|
+
})
|
|
1878
|
+
} else {
|
|
1879
|
+
showDayDetailSidebar({
|
|
1880
|
+
date: label,
|
|
1881
|
+
count: filteredCommits.length,
|
|
1882
|
+
commits: filteredCommits,
|
|
1883
|
+
titleDrawer: `${author} 变更量 ${type} 详情`
|
|
1884
|
+
})
|
|
1885
|
+
}
|
|
1886
|
+
} catch (err) {
|
|
1887
|
+
console.warn('Change chart click handler error', err)
|
|
1888
|
+
}
|
|
1889
|
+
})
|
|
1890
|
+
|
|
1550
1891
|
return chart
|
|
1551
1892
|
}
|
|
1552
1893
|
|
|
@@ -1595,6 +1936,7 @@ function drawAuthorOvertimeTrends(commits, stats) {
|
|
|
1595
1936
|
const el = document.getElementById('chartAuthorOvertime')
|
|
1596
1937
|
if (!el) return null
|
|
1597
1938
|
const chart = echarts.init(el)
|
|
1939
|
+
chartInstances.push(chart)
|
|
1598
1940
|
|
|
1599
1941
|
const startHour =
|
|
1600
1942
|
typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
@@ -1642,6 +1984,11 @@ function drawAuthorOvertimeTrends(commits, stats) {
|
|
|
1642
1984
|
|
|
1643
1985
|
const lines = params
|
|
1644
1986
|
.filter((i) => i.data > 0)
|
|
1987
|
+
.sort(
|
|
1988
|
+
(a, b) =>
|
|
1989
|
+
(b.data || 0) - (a.data || 0) ||
|
|
1990
|
+
String(a.seriesName).localeCompare(String(b.seriesName))
|
|
1991
|
+
)
|
|
1645
1992
|
.map(
|
|
1646
1993
|
(item) => `${item.marker}${item.seriesName}: ${item.data} 次提交`
|
|
1647
1994
|
)
|
|
@@ -1676,13 +2023,75 @@ function drawAuthorOvertimeTrends(commits, stats) {
|
|
|
1676
2023
|
})
|
|
1677
2024
|
})
|
|
1678
2025
|
|
|
1679
|
-
//
|
|
2026
|
+
// 输出本周风险与加班时长排行
|
|
1680
2027
|
renderWeeklyRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1681
2028
|
renderMonthlyRiskSummary(commits, { startHour, endHour, cutoff })
|
|
2029
|
+
// 新增:本周/本月加班时长排名(显示所有作者总时长,前三名带图标,状元标注“夜魔侠”)
|
|
2030
|
+
renderWeeklyDurationRankSummary(commits, { startHour, endHour, cutoff })
|
|
1682
2031
|
renderWeeklyDurationRiskSummary(commits, { startHour, endHour, cutoff })
|
|
2032
|
+
renderMonthlyDurationRankSummary(commits, { startHour, endHour, cutoff })
|
|
1683
2033
|
renderMonthlyDurationRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1684
2034
|
renderRolling30DurationRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1685
2035
|
|
|
2036
|
+
// 点击事件:点击某个作者在某周期的点,打开侧栏显示该作者在该周期内的下班后提交(加班)明细
|
|
2037
|
+
chart.on('click', (p) => {
|
|
2038
|
+
try {
|
|
2039
|
+
if (!p || p.componentType !== 'series') return
|
|
2040
|
+
const label = p.axisValue || p.name
|
|
2041
|
+
const author = p.seriesName
|
|
2042
|
+
if (!label || !author) return
|
|
2043
|
+
const type =
|
|
2044
|
+
document.querySelector('#tabsOvertime button.active')?.dataset.type ||
|
|
2045
|
+
'daily'
|
|
2046
|
+
|
|
2047
|
+
const filteredCommits = commits.filter((c) => {
|
|
2048
|
+
const a = c.author || 'unknown'
|
|
2049
|
+
if (a !== author) return false
|
|
2050
|
+
const d = new Date(c.date)
|
|
2051
|
+
if (Number.isNaN(d.valueOf())) return false
|
|
2052
|
+
const h = d.getHours()
|
|
2053
|
+
const isOT =
|
|
2054
|
+
(h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
|
|
2055
|
+
if (!isOT) return false
|
|
2056
|
+
|
|
2057
|
+
if (type === 'daily') return d.toISOString().slice(0, 10) === label
|
|
2058
|
+
if (type === 'weekly') {
|
|
2059
|
+
if (!label.includes('-W')) return false
|
|
2060
|
+
const [yy, ww] = label.split('-W')
|
|
2061
|
+
const range = getISOWeekRange(Number(yy), Number(ww))
|
|
2062
|
+
const day = d.toISOString().slice(0, 10)
|
|
2063
|
+
return day >= range.start && day <= range.end
|
|
2064
|
+
}
|
|
2065
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
2066
|
+
return month === label
|
|
2067
|
+
})
|
|
2068
|
+
|
|
2069
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
2070
|
+
|
|
2071
|
+
if (type === 'weekly') {
|
|
2072
|
+
const weeklyItem = {
|
|
2073
|
+
outsideWorkCount: filteredCommits.length,
|
|
2074
|
+
outsideWorkRate: 0
|
|
2075
|
+
}
|
|
2076
|
+
showSideBarForWeek({
|
|
2077
|
+
period: label,
|
|
2078
|
+
weeklyItem,
|
|
2079
|
+
commits: filteredCommits,
|
|
2080
|
+
titleDrawer: `${author} 加班本周详情`
|
|
2081
|
+
})
|
|
2082
|
+
} else {
|
|
2083
|
+
showDayDetailSidebar({
|
|
2084
|
+
date: label,
|
|
2085
|
+
count: filteredCommits.length,
|
|
2086
|
+
commits: filteredCommits,
|
|
2087
|
+
titleDrawer: `${author} 加班 ${type} 详情`
|
|
2088
|
+
})
|
|
2089
|
+
}
|
|
2090
|
+
} catch (err) {
|
|
2091
|
+
console.warn('Overtime chart click handler error', err)
|
|
2092
|
+
}
|
|
2093
|
+
})
|
|
2094
|
+
|
|
1686
2095
|
return chart
|
|
1687
2096
|
}
|
|
1688
2097
|
|
|
@@ -1770,6 +2179,65 @@ function renderWeeklyRiskSummary(
|
|
|
1770
2179
|
)
|
|
1771
2180
|
}
|
|
1772
2181
|
|
|
2182
|
+
// ---------- 追加:本周“最晚加班风险”分析(合并自原 renderLatestRiskSummary) ----------
|
|
2183
|
+
const weekMax = new Map()
|
|
2184
|
+
commits.forEach((c) => {
|
|
2185
|
+
const d = new Date(c.date)
|
|
2186
|
+
if (Number.isNaN(d.valueOf())) return
|
|
2187
|
+
const h = d.getHours()
|
|
2188
|
+
let overtime = null
|
|
2189
|
+
if (h >= endHour && h < 24) overtime = h - endHour
|
|
2190
|
+
else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
|
|
2191
|
+
if (overtime == null) return
|
|
2192
|
+
|
|
2193
|
+
const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
2194
|
+
if (!wKey) return
|
|
2195
|
+
if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
|
|
2196
|
+
const m = weekMax.get(wKey)
|
|
2197
|
+
const author = c.author || 'unknown'
|
|
2198
|
+
const cur = m.get(author)
|
|
2199
|
+
if (!cur || overtime > cur.max) {
|
|
2200
|
+
m.set(author, { max: overtime, date: d.toISOString().slice(0, 10) })
|
|
2201
|
+
}
|
|
2202
|
+
})
|
|
2203
|
+
|
|
2204
|
+
const curMaxMap = weekMax.get(curKey) || new Map()
|
|
2205
|
+
const prevMaxMap = weekMax.get(prevKey) || new Map()
|
|
2206
|
+
let topAuthorLatest = null
|
|
2207
|
+
let topLatest = { max: -1, date: null }
|
|
2208
|
+
curMaxMap.forEach((v, k) => {
|
|
2209
|
+
if (v.max > topLatest.max) {
|
|
2210
|
+
topLatest = v
|
|
2211
|
+
topAuthorLatest = k
|
|
2212
|
+
}
|
|
2213
|
+
})
|
|
2214
|
+
let prevMaxLatest = -1
|
|
2215
|
+
prevMaxMap.forEach((v) => {
|
|
2216
|
+
if (v.max > prevMaxLatest) prevMaxLatest = v.max
|
|
2217
|
+
})
|
|
2218
|
+
|
|
2219
|
+
const latestLines = []
|
|
2220
|
+
latestLines.push('【本周最晚加班风险】')
|
|
2221
|
+
|
|
2222
|
+
if (topLatest.max < 0) {
|
|
2223
|
+
latestLines.push('本周尚无下班后/凌晨提交,未发现明显风险。')
|
|
2224
|
+
} else {
|
|
2225
|
+
let trend2 = '暂无上周对比'
|
|
2226
|
+
if (prevMaxLatest >= 0) {
|
|
2227
|
+
if (topLatest.max > prevMaxLatest) trend2 = '较上周更晚'
|
|
2228
|
+
else if (topLatest.max < prevMaxLatest) trend2 = '较上周提前'
|
|
2229
|
+
else trend2 = '与上周持平'
|
|
2230
|
+
}
|
|
2231
|
+
latestLines.push(
|
|
2232
|
+
`${topAuthorLatest} 本周最晚超出下班 ${topLatest.max.toFixed(2)} 小时(${topLatest.date}),${trend2}。`
|
|
2233
|
+
)
|
|
2234
|
+
if (topLatest.max >= 2) {
|
|
2235
|
+
latestLines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
|
|
2236
|
+
} else if (topLatest.max >= 1) {
|
|
2237
|
+
latestLines.push('已超过 1 小时,注意控制夜间工作时长。')
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
1773
2241
|
box.innerHTML = `
|
|
1774
2242
|
<div class="risk-summary">
|
|
1775
2243
|
<div class="risk-title">【本周风险总结】</div>
|
|
@@ -1780,6 +2248,16 @@ function renderWeeklyRiskSummary(
|
|
|
1780
2248
|
.join('')}
|
|
1781
2249
|
</ul>
|
|
1782
2250
|
</div>
|
|
2251
|
+
|
|
2252
|
+
<div class="risk-summary">
|
|
2253
|
+
<div class="risk-title">【本周最晚加班风险】</div>
|
|
2254
|
+
<ul>
|
|
2255
|
+
${latestLines
|
|
2256
|
+
.slice(1)
|
|
2257
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2258
|
+
.join('')}
|
|
2259
|
+
</ul>
|
|
2260
|
+
</div>
|
|
1783
2261
|
`
|
|
1784
2262
|
}
|
|
1785
2263
|
|
|
@@ -1812,6 +2290,62 @@ function computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff) {
|
|
|
1812
2290
|
return byAuthorDay
|
|
1813
2291
|
}
|
|
1814
2292
|
|
|
2293
|
+
function renderWeeklyDurationRankSummary(
|
|
2294
|
+
commits,
|
|
2295
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
2296
|
+
) {
|
|
2297
|
+
const box = document.getElementById('weeklyDurationRankSummary')
|
|
2298
|
+
if (!box) return
|
|
2299
|
+
const now = new Date()
|
|
2300
|
+
const curWeek = getIsoWeekKey(now.toISOString().slice(0, 10))
|
|
2301
|
+
const byAuthorDay = computeAuthorDailyMaxOvertime(
|
|
2302
|
+
commits,
|
|
2303
|
+
startHour,
|
|
2304
|
+
endHour,
|
|
2305
|
+
cutoff
|
|
2306
|
+
)
|
|
2307
|
+
const ranks = []
|
|
2308
|
+
byAuthorDay.forEach((dayMap, author) => {
|
|
2309
|
+
let total = 0
|
|
2310
|
+
dayMap.forEach((v, dayKey) => {
|
|
2311
|
+
const wk = getIsoWeekKey(dayKey)
|
|
2312
|
+
if (wk === curWeek) total += v
|
|
2313
|
+
})
|
|
2314
|
+
if (total > 0) ranks.push({ author, total })
|
|
2315
|
+
})
|
|
2316
|
+
ranks.sort(
|
|
2317
|
+
(a, b) =>
|
|
2318
|
+
b.total - a.total || String(a.author).localeCompare(String(b.author))
|
|
2319
|
+
)
|
|
2320
|
+
|
|
2321
|
+
const lines = []
|
|
2322
|
+
lines.push('【本周加班时长排名】')
|
|
2323
|
+
if (ranks.length === 0) {
|
|
2324
|
+
lines.push('本周暂无加班时长。')
|
|
2325
|
+
} else {
|
|
2326
|
+
ranks.forEach((r, idx) => {
|
|
2327
|
+
const rank = idx + 1
|
|
2328
|
+
const medal =
|
|
2329
|
+
rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
|
|
2330
|
+
const title = rank === 1 ? '(状元・夜魔侠)' : ''
|
|
2331
|
+
lines.push(
|
|
2332
|
+
`${rank}. ${medal}${r.author} — ${r.total.toFixed(2)} 小时${title}`
|
|
2333
|
+
)
|
|
2334
|
+
})
|
|
2335
|
+
}
|
|
2336
|
+
box.innerHTML = `
|
|
2337
|
+
<div class="risk-summary">
|
|
2338
|
+
<div class="risk-title">【本周加班时长排名】</div>
|
|
2339
|
+
<ul>
|
|
2340
|
+
${lines
|
|
2341
|
+
.slice(1)
|
|
2342
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2343
|
+
.join('')}
|
|
2344
|
+
</ul>
|
|
2345
|
+
</div>
|
|
2346
|
+
`
|
|
2347
|
+
}
|
|
2348
|
+
|
|
1815
2349
|
function renderWeeklyDurationRiskSummary(
|
|
1816
2350
|
commits,
|
|
1817
2351
|
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
@@ -1864,6 +2398,62 @@ function renderWeeklyDurationRiskSummary(
|
|
|
1864
2398
|
`
|
|
1865
2399
|
}
|
|
1866
2400
|
|
|
2401
|
+
function renderMonthlyDurationRankSummary(
|
|
2402
|
+
commits,
|
|
2403
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
2404
|
+
) {
|
|
2405
|
+
const box = document.getElementById('monthlyDurationRankSummary')
|
|
2406
|
+
if (!box) return
|
|
2407
|
+
const now = new Date()
|
|
2408
|
+
const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
2409
|
+
const byAuthorDay = computeAuthorDailyMaxOvertime(
|
|
2410
|
+
commits,
|
|
2411
|
+
startHour,
|
|
2412
|
+
endHour,
|
|
2413
|
+
cutoff
|
|
2414
|
+
)
|
|
2415
|
+
const ranks = []
|
|
2416
|
+
byAuthorDay.forEach((dayMap, author) => {
|
|
2417
|
+
let total = 0
|
|
2418
|
+
dayMap.forEach((v, dayKey) => {
|
|
2419
|
+
const m = dayKey.slice(0, 7)
|
|
2420
|
+
if (m === curMonth) total += v
|
|
2421
|
+
})
|
|
2422
|
+
if (total > 0) ranks.push({ author, total })
|
|
2423
|
+
})
|
|
2424
|
+
ranks.sort(
|
|
2425
|
+
(a, b) =>
|
|
2426
|
+
b.total - a.total || String(a.author).localeCompare(String(b.author))
|
|
2427
|
+
)
|
|
2428
|
+
|
|
2429
|
+
const lines = []
|
|
2430
|
+
lines.push('【本月加班时长排名】')
|
|
2431
|
+
if (ranks.length === 0) {
|
|
2432
|
+
lines.push('本月暂无加班时长。')
|
|
2433
|
+
} else {
|
|
2434
|
+
ranks.forEach((r, idx) => {
|
|
2435
|
+
const rank = idx + 1
|
|
2436
|
+
const medal =
|
|
2437
|
+
rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
|
|
2438
|
+
const title = rank === 1 ? '(状元・夜魔侠)' : ''
|
|
2439
|
+
lines.push(
|
|
2440
|
+
`${rank}. ${medal}${r.author} — ${r.total.toFixed(2)} 小时${title}`
|
|
2441
|
+
)
|
|
2442
|
+
})
|
|
2443
|
+
}
|
|
2444
|
+
box.innerHTML = `
|
|
2445
|
+
<div class="risk-summary">
|
|
2446
|
+
<div class="risk-title">【本月加班时长排名】</div>
|
|
2447
|
+
<ul>
|
|
2448
|
+
${lines
|
|
2449
|
+
.slice(1)
|
|
2450
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2451
|
+
.join('')}
|
|
2452
|
+
</ul>
|
|
2453
|
+
</div>
|
|
2454
|
+
`
|
|
2455
|
+
}
|
|
2456
|
+
|
|
1867
2457
|
function renderMonthlyDurationRiskSummary(
|
|
1868
2458
|
commits,
|
|
1869
2459
|
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
@@ -2068,13 +2658,45 @@ function renderMonthlyRiskSummary(
|
|
|
2068
2658
|
}
|
|
2069
2659
|
}
|
|
2070
2660
|
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2661
|
+
// ---------- 追加:本月“最晚加班风险”独立展示(合并自 renderLatestMonthlyRiskSummary) ----------
|
|
2662
|
+
const latestMonthLines = []
|
|
2663
|
+
latestMonthLines.push('【本月最晚加班风险】')
|
|
2664
|
+
if (top.max < 0) {
|
|
2665
|
+
latestMonthLines.push('本月尚无下班后/凌晨提交,未发现明显风险。')
|
|
2666
|
+
} else {
|
|
2667
|
+
let trend3 = '暂无上月对比'
|
|
2668
|
+
if (prevMax >= 0) {
|
|
2669
|
+
if (top.max > prevMax) trend3 = '较上月更晚'
|
|
2670
|
+
else if (top.max < prevMax) trend3 = '较上月提前'
|
|
2671
|
+
else trend3 = '与上月持平'
|
|
2672
|
+
}
|
|
2673
|
+
latestMonthLines.push(
|
|
2674
|
+
`${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend3}。`
|
|
2675
|
+
)
|
|
2676
|
+
if (top.max >= 2) {
|
|
2677
|
+
latestMonthLines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
|
|
2678
|
+
} else if (top.max >= 1) {
|
|
2679
|
+
latestMonthLines.push('已超过 1 小时,注意控制夜间工作时长。')
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
box.innerHTML = `
|
|
2684
|
+
<div class="risk-summary">
|
|
2685
|
+
<div class="risk-title">【本月加班风险】</div>
|
|
2686
|
+
<ul>
|
|
2687
|
+
${lines
|
|
2688
|
+
.slice(1)
|
|
2689
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2690
|
+
.join('')}
|
|
2691
|
+
</ul>
|
|
2692
|
+
</div>
|
|
2693
|
+
|
|
2694
|
+
<div class="risk-summary">
|
|
2695
|
+
<div class="risk-title">【本月最晚加班风险】</div>
|
|
2696
|
+
<ul>
|
|
2697
|
+
${latestMonthLines
|
|
2698
|
+
.slice(1)
|
|
2699
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2078
2700
|
.join('')}
|
|
2079
2701
|
</ul>
|
|
2080
2702
|
</div>
|
|
@@ -2129,6 +2751,7 @@ function drawAuthorLatestOvertimeTrends(commits, stats) {
|
|
|
2129
2751
|
const el = document.getElementById('chartAuthorLatestOvertime')
|
|
2130
2752
|
if (!el) return null
|
|
2131
2753
|
const chart = echarts.init(el)
|
|
2754
|
+
chartInstances.push(chart)
|
|
2132
2755
|
|
|
2133
2756
|
const startHour =
|
|
2134
2757
|
typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
@@ -2176,6 +2799,11 @@ function drawAuthorLatestOvertimeTrends(commits, stats) {
|
|
|
2176
2799
|
|
|
2177
2800
|
const lines = params
|
|
2178
2801
|
.filter((i) => i.data > 0)
|
|
2802
|
+
.sort(
|
|
2803
|
+
(a, b) =>
|
|
2804
|
+
(b.data || 0) - (a.data || 0) ||
|
|
2805
|
+
String(a.seriesName).localeCompare(String(b.seriesName))
|
|
2806
|
+
)
|
|
2179
2807
|
.map(
|
|
2180
2808
|
(item) => `${item.marker}${item.seriesName}: ${item.data} 小时`
|
|
2181
2809
|
)
|
|
@@ -2210,207 +2838,2495 @@ function drawAuthorLatestOvertimeTrends(commits, stats) {
|
|
|
2210
2838
|
})
|
|
2211
2839
|
})
|
|
2212
2840
|
|
|
2213
|
-
|
|
2214
|
-
|
|
2841
|
+
// 点击事件:点击某个作者在某周期的点,打开侧栏显示该作者在该周期内的加班提交明细(用于查看具体提交与时间)
|
|
2842
|
+
chart.on('click', (p) => {
|
|
2843
|
+
try {
|
|
2844
|
+
if (!p || p.componentType !== 'series') return
|
|
2845
|
+
const label = p.axisValue || p.name
|
|
2846
|
+
const author = p.seriesName
|
|
2847
|
+
if (!label || !author) return
|
|
2848
|
+
const type =
|
|
2849
|
+
document.querySelector('#tabsLatestOvertime button.active')?.dataset
|
|
2850
|
+
.type || 'daily'
|
|
2851
|
+
|
|
2852
|
+
const filteredCommits = commits.filter((c) => {
|
|
2853
|
+
const a = c.author || 'unknown'
|
|
2854
|
+
if (a !== author) return false
|
|
2855
|
+
const d = new Date(c.date)
|
|
2856
|
+
if (Number.isNaN(d.valueOf())) return false
|
|
2857
|
+
const h = d.getHours()
|
|
2858
|
+
let overtime = null
|
|
2859
|
+
if (h >= endHour && h < 24) overtime = h - endHour
|
|
2860
|
+
else if (h >= 0 && h < cutoff && h < startHour)
|
|
2861
|
+
overtime = 24 - endHour + h
|
|
2862
|
+
if (overtime == null) return false
|
|
2863
|
+
|
|
2864
|
+
if (type === 'daily') return d.toISOString().slice(0, 10) === label
|
|
2865
|
+
if (type === 'weekly') {
|
|
2866
|
+
if (!label.includes('-W')) return false
|
|
2867
|
+
const [yy, ww] = label.split('-W')
|
|
2868
|
+
const range = getISOWeekRange(Number(yy), Number(ww))
|
|
2869
|
+
const day = d.toISOString().slice(0, 10)
|
|
2870
|
+
return day >= range.start && day <= range.end
|
|
2871
|
+
}
|
|
2872
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
2873
|
+
return month === label
|
|
2874
|
+
})
|
|
2875
|
+
|
|
2876
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
2877
|
+
|
|
2878
|
+
if (type === 'weekly') {
|
|
2879
|
+
const weeklyItem = {
|
|
2880
|
+
outsideWorkCount: filteredCommits.length,
|
|
2881
|
+
outsideWorkRate: 0
|
|
2882
|
+
}
|
|
2883
|
+
showSideBarForWeek({
|
|
2884
|
+
period: label,
|
|
2885
|
+
weeklyItem,
|
|
2886
|
+
commits: filteredCommits,
|
|
2887
|
+
titleDrawer: `${author} 本周最晚加班详情`
|
|
2888
|
+
})
|
|
2889
|
+
} else {
|
|
2890
|
+
showDayDetailSidebar({
|
|
2891
|
+
date: label,
|
|
2892
|
+
count: filteredCommits.length,
|
|
2893
|
+
commits: filteredCommits,
|
|
2894
|
+
titleDrawer: `${author} 本日最晚加班详情`
|
|
2895
|
+
})
|
|
2896
|
+
}
|
|
2897
|
+
} catch (err) {
|
|
2898
|
+
console.warn('Latest overtime chart click handler error', err)
|
|
2899
|
+
}
|
|
2900
|
+
})
|
|
2215
2901
|
|
|
2216
2902
|
return chart
|
|
2217
2903
|
}
|
|
2218
2904
|
|
|
2219
|
-
//
|
|
2220
|
-
function
|
|
2905
|
+
// ====== 开发者 累计加班时长(按日/周/月/年累计日峰值加班时长求和) ======
|
|
2906
|
+
function buildAuthorTotalOvertimeDataset(
|
|
2221
2907
|
commits,
|
|
2222
|
-
|
|
2908
|
+
type,
|
|
2909
|
+
startHour,
|
|
2910
|
+
endHour,
|
|
2911
|
+
cutoff
|
|
2223
2912
|
) {
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
const
|
|
2232
|
-
|
|
2233
|
-
// 统计每周每人最大超时
|
|
2234
|
-
const weekMax = new Map() // week -> Map(author -> {max, date})
|
|
2235
|
-
commits.forEach((c) => {
|
|
2236
|
-
const d = new Date(c.date)
|
|
2237
|
-
if (Number.isNaN(d.valueOf())) return
|
|
2238
|
-
const h = d.getHours()
|
|
2239
|
-
let overtime = null
|
|
2240
|
-
if (h >= endHour && h < 24) overtime = h - endHour
|
|
2241
|
-
else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
|
|
2242
|
-
if (overtime == null) return
|
|
2913
|
+
// 基于每天每人的最大超时(computeAuthorDailyMaxOvertime),再按周期聚合求和
|
|
2914
|
+
const byAuthorDay = computeAuthorDailyMaxOvertime(
|
|
2915
|
+
commits,
|
|
2916
|
+
startHour,
|
|
2917
|
+
endHour,
|
|
2918
|
+
cutoff
|
|
2919
|
+
)
|
|
2920
|
+
const byAuthorPeriod = new Map()
|
|
2921
|
+
const periods = new Set()
|
|
2243
2922
|
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2923
|
+
byAuthorDay.forEach((dayMap, author) => {
|
|
2924
|
+
for (const [dayKey, overtime] of dayMap.entries()) {
|
|
2925
|
+
let period
|
|
2926
|
+
if (type === 'daily') period = dayKey
|
|
2927
|
+
else if (type === 'weekly') period = getIsoWeekKey(dayKey)
|
|
2928
|
+
else if (type === 'monthly')
|
|
2929
|
+
period = dayKey.slice(0, 7) // YYYY-MM
|
|
2930
|
+
else if (type === 'yearly')
|
|
2931
|
+
period = dayKey.slice(0, 4) // YYYY
|
|
2932
|
+
else period = dayKey
|
|
2933
|
+
if (!period) continue
|
|
2934
|
+
periods.add(period)
|
|
2935
|
+
if (!byAuthorPeriod.has(author)) byAuthorPeriod.set(author, {})
|
|
2936
|
+
const obj = byAuthorPeriod.get(author)
|
|
2937
|
+
obj[period] = (obj[period] || 0) + (overtime || 0)
|
|
2252
2938
|
}
|
|
2253
2939
|
})
|
|
2254
2940
|
|
|
2255
|
-
const
|
|
2256
|
-
const
|
|
2941
|
+
const allPeriods = Array.from(periods).sort()
|
|
2942
|
+
const authors = Array.from(byAuthorPeriod.keys()).sort()
|
|
2943
|
+
const series = authors.map((a) => ({
|
|
2944
|
+
name: a,
|
|
2945
|
+
type: 'line',
|
|
2946
|
+
smooth: true,
|
|
2947
|
+
data: allPeriods.map((p) =>
|
|
2948
|
+
Number((byAuthorPeriod.get(a)[p] || 0).toFixed(2))
|
|
2949
|
+
)
|
|
2950
|
+
}))
|
|
2257
2951
|
|
|
2258
|
-
//
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2952
|
+
// 3. 计算每个作者的总时长/占用天数/日均占用,方便列表与 tooltip 使用
|
|
2953
|
+
const totals = authors.map((a) => {
|
|
2954
|
+
const periodObj = byAuthorPeriod.get(a) || {}
|
|
2955
|
+
const totalHours = Object.values(periodObj).reduce(
|
|
2956
|
+
(s, v) => s + (Number(v) || 0),
|
|
2957
|
+
0
|
|
2958
|
+
)
|
|
2959
|
+
// days 从 byAuthorDay(每日最大超时的 map)中获取
|
|
2960
|
+
const days = byAuthorDay.get(a) ? byAuthorDay.get(a).size : 0
|
|
2961
|
+
const avg = days > 0 ? Number((totalHours / days).toFixed(2)) : 0
|
|
2962
|
+
return { author: a, totalHours: Number(totalHours.toFixed(2)), days, avg }
|
|
2266
2963
|
})
|
|
2267
2964
|
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
prevMap.forEach((v) => {
|
|
2271
|
-
if (v.max > prevMax) prevMax = v.max
|
|
2272
|
-
})
|
|
2965
|
+
return { authors, allPeriods, series, totals }
|
|
2966
|
+
}
|
|
2273
2967
|
|
|
2274
|
-
|
|
2275
|
-
|
|
2968
|
+
function drawAuthorTotalOvertimeTrends(commits, stats) {
|
|
2969
|
+
const el = document.getElementById('chartAuthorTotalOvertime')
|
|
2970
|
+
if (!el) return null
|
|
2971
|
+
const chart = echarts.init(el)
|
|
2972
|
+
chartInstances.push(chart)
|
|
2276
2973
|
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2974
|
+
const startHour =
|
|
2975
|
+
typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
2976
|
+
? stats.startHour
|
|
2977
|
+
: 9
|
|
2978
|
+
const endHour =
|
|
2979
|
+
typeof stats.endHour === 'number' && stats.endHour >= 0
|
|
2980
|
+
? stats.endHour
|
|
2981
|
+
: window.__overtimeEndHour || 18
|
|
2982
|
+
const cutoff = window.__overnightCutoff ?? 6
|
|
2983
|
+
|
|
2984
|
+
function render(type) {
|
|
2985
|
+
const ds = buildAuthorTotalOvertimeDataset(
|
|
2986
|
+
commits,
|
|
2987
|
+
type,
|
|
2988
|
+
startHour,
|
|
2989
|
+
endHour,
|
|
2990
|
+
cutoff
|
|
2290
2991
|
)
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2992
|
+
ds.rangeMap = {}
|
|
2993
|
+
for (const period of ds.allPeriods) {
|
|
2994
|
+
if (period.includes('-W')) {
|
|
2995
|
+
const [yy, ww] = period.split('-W')
|
|
2996
|
+
ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
chart.setOption({
|
|
3001
|
+
tooltip: {
|
|
3002
|
+
trigger: 'axis',
|
|
3003
|
+
formatter(params) {
|
|
3004
|
+
if (!params || !params.length) return ''
|
|
3005
|
+
const label = params[0].axisValue
|
|
3006
|
+
const isWeekly = type === 'weekly'
|
|
3007
|
+
let extra = ''
|
|
3008
|
+
if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
|
|
3009
|
+
const { start, end } = ds.rangeMap[label]
|
|
3010
|
+
extra = `<div style="margin-top:4px;color:#999;font-size:12px">周区间:${start} ~ ${end}</div>`
|
|
3011
|
+
}
|
|
3012
|
+
const lines = params
|
|
3013
|
+
.filter((i) => i.data > 0)
|
|
3014
|
+
.sort(
|
|
3015
|
+
(a, b) =>
|
|
3016
|
+
(b.data || 0) - (a.data || 0) ||
|
|
3017
|
+
String(a.seriesName).localeCompare(String(b.seriesName))
|
|
3018
|
+
)
|
|
3019
|
+
.map(
|
|
3020
|
+
(item) => `${item.marker}${item.seriesName}: ${item.data} 小时`
|
|
3021
|
+
)
|
|
3022
|
+
.join('<br/>')
|
|
3023
|
+
return `<div>${label}</div>${extra}${lines}`
|
|
3024
|
+
}
|
|
3025
|
+
},
|
|
3026
|
+
legend: { data: ds.authors },
|
|
3027
|
+
xAxis: { type: 'category', data: ds.allPeriods },
|
|
3028
|
+
yAxis: { type: 'value', name: '累计加班时长 (小时)' },
|
|
3029
|
+
series: ds.series
|
|
3030
|
+
})
|
|
3031
|
+
|
|
3032
|
+
// 同步更新下面的排名列表
|
|
3033
|
+
try {
|
|
3034
|
+
renderAuthorTotalOvertimeRankFromDs(ds, 0)
|
|
3035
|
+
renderAuthorTotalOvertimeRank(ds, 0)
|
|
3036
|
+
} catch (e) {
|
|
3037
|
+
console.warn('更新累计加班排名失败', e)
|
|
2295
3038
|
}
|
|
2296
3039
|
}
|
|
2297
3040
|
|
|
2298
|
-
|
|
2299
|
-
<div class="risk-summary">
|
|
2300
|
-
<div class="risk-title">【本周最晚加班风险】</div>
|
|
2301
|
-
<ul>
|
|
2302
|
-
${lines
|
|
2303
|
-
.slice(1)
|
|
2304
|
-
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2305
|
-
.join('')}
|
|
2306
|
-
</ul>
|
|
2307
|
-
</div>
|
|
2308
|
-
`
|
|
2309
|
-
}
|
|
3041
|
+
render('daily')
|
|
2310
3042
|
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
)
|
|
2315
|
-
|
|
2316
|
-
|
|
3043
|
+
const tabs = document.querySelectorAll('#tabsTotalOvertime button')
|
|
3044
|
+
tabs.forEach((btnEl) => {
|
|
3045
|
+
btnEl.addEventListener('click', () => {
|
|
3046
|
+
tabs.forEach((b) => b.classList.remove('active'))
|
|
3047
|
+
btnEl.classList.add('active')
|
|
3048
|
+
render(btnEl.dataset.type)
|
|
3049
|
+
})
|
|
3050
|
+
})
|
|
2317
3051
|
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
3052
|
+
// 点击事件:展示该作者在该周期的加班详情(过滤出下班/凌晨提交)
|
|
3053
|
+
chart.on('click', (p) => {
|
|
3054
|
+
try {
|
|
3055
|
+
if (!p || p.componentType !== 'series') return
|
|
3056
|
+
const label = p.axisValue || p.name
|
|
3057
|
+
const author = p.seriesName
|
|
3058
|
+
if (!label || !author) return
|
|
3059
|
+
const type =
|
|
3060
|
+
document.querySelector('#tabsTotalOvertime button.active')?.dataset
|
|
3061
|
+
.type || 'daily'
|
|
3062
|
+
|
|
3063
|
+
const filteredCommits = commits.filter((c) => {
|
|
3064
|
+
const a = c.author || 'unknown'
|
|
3065
|
+
if (a !== author) return false
|
|
3066
|
+
const d = new Date(c.date)
|
|
3067
|
+
if (Number.isNaN(d.valueOf())) return false
|
|
3068
|
+
const h = d.getHours()
|
|
3069
|
+
const isOT =
|
|
3070
|
+
(h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
|
|
3071
|
+
if (!isOT) return false
|
|
3072
|
+
|
|
3073
|
+
if (type === 'daily') return d.toISOString().slice(0, 10) === label
|
|
3074
|
+
if (type === 'weekly') {
|
|
3075
|
+
if (!label.includes('-W')) return false
|
|
3076
|
+
const [yy, ww] = label.split('-W')
|
|
3077
|
+
const range = getISOWeekRange(Number(yy), Number(ww))
|
|
3078
|
+
const day = d.toISOString().slice(0, 10)
|
|
3079
|
+
return day >= range.start && day <= range.end
|
|
3080
|
+
}
|
|
3081
|
+
if (type === 'monthly') {
|
|
3082
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
3083
|
+
return month === label
|
|
3084
|
+
}
|
|
3085
|
+
// yearly
|
|
3086
|
+
const year = String(d.getFullYear())
|
|
3087
|
+
return year === label
|
|
3088
|
+
})
|
|
2323
3089
|
|
|
2324
|
-
|
|
2325
|
-
commits.forEach((c) => {
|
|
2326
|
-
const d = new Date(c.date)
|
|
2327
|
-
if (Number.isNaN(d.valueOf())) return
|
|
2328
|
-
const h = d.getHours()
|
|
2329
|
-
let overtime = null
|
|
2330
|
-
if (h >= endHour && h < 24) overtime = h - endHour
|
|
2331
|
-
else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
|
|
2332
|
-
if (overtime == null) return
|
|
3090
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
2333
3091
|
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
3092
|
+
if (type === 'weekly') {
|
|
3093
|
+
const weeklyItem = {
|
|
3094
|
+
outsideWorkCount: filteredCommits.length,
|
|
3095
|
+
outsideWorkRate: 0
|
|
3096
|
+
}
|
|
3097
|
+
showSideBarForWeek({
|
|
3098
|
+
period: label,
|
|
3099
|
+
weeklyItem,
|
|
3100
|
+
commits: filteredCommits,
|
|
3101
|
+
titleDrawer: `${author} 累计加班 本周详情`
|
|
3102
|
+
})
|
|
3103
|
+
} else {
|
|
3104
|
+
showDayDetailSidebar({
|
|
3105
|
+
date: label,
|
|
3106
|
+
count: filteredCommits.length,
|
|
3107
|
+
commits: filteredCommits,
|
|
3108
|
+
titleDrawer: `${author} 累计加班 ${type} 详情`
|
|
3109
|
+
})
|
|
3110
|
+
}
|
|
3111
|
+
} catch (err) {
|
|
3112
|
+
console.warn('Total overtime chart click handler error', err)
|
|
2341
3113
|
}
|
|
2342
3114
|
})
|
|
2343
3115
|
|
|
2344
|
-
|
|
2345
|
-
|
|
3116
|
+
return chart
|
|
3117
|
+
}
|
|
2346
3118
|
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
3119
|
+
/**
|
|
3120
|
+
* 渲染作者加班时长分布饼图
|
|
3121
|
+
* @param {Object} ds - 数据源
|
|
3122
|
+
* @param {Number} topN - 饼图展示的前N名,默认10,设为0则展示全部(不推荐在饼图中设为0)
|
|
3123
|
+
*/
|
|
3124
|
+
function renderAuthorTotalOvertimeRank(ds, topN = 10) {
|
|
3125
|
+
// FIXME: remove debug log before production
|
|
3126
|
+
console.log('❌', 'ds', ds)
|
|
3127
|
+
if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) return
|
|
3128
|
+
|
|
3129
|
+
// 1. 数据预处理:计算每个作者的总时长
|
|
3130
|
+
const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
|
|
3131
|
+
|
|
3132
|
+
const totals = ds.authors.map((author) => {
|
|
3133
|
+
const data = seriesMap.get(author)
|
|
3134
|
+
const total = Array.isArray(data)
|
|
3135
|
+
? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
|
|
3136
|
+
: 0
|
|
3137
|
+
return { name: author, value: Number(total.toFixed(2)) }
|
|
2354
3138
|
})
|
|
2355
3139
|
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
3140
|
+
// 2. 排序:从高到低
|
|
3141
|
+
totals.sort((a, b) => b.value - a.value)
|
|
3142
|
+
|
|
3143
|
+
// 3. 核心逻辑:处理 topN 和 “其他” 逻辑
|
|
3144
|
+
let chartData = []
|
|
3145
|
+
if (topN > 0 && totals.length > topN) {
|
|
3146
|
+
// 截取前 N 名
|
|
3147
|
+
chartData = totals.slice(0, topN)
|
|
3148
|
+
// 汇总剩余的为“其他”
|
|
3149
|
+
const othersValue = totals
|
|
3150
|
+
.slice(topN)
|
|
3151
|
+
.reduce((sum, item) => sum + item.value, 0)
|
|
3152
|
+
chartData.push({
|
|
3153
|
+
name: '其他',
|
|
3154
|
+
value: Number(othersValue.toFixed(2))
|
|
3155
|
+
})
|
|
3156
|
+
} else {
|
|
3157
|
+
// topN 为 0 时展示全部
|
|
3158
|
+
chartData = totals
|
|
3159
|
+
}
|
|
2360
3160
|
|
|
2361
|
-
|
|
2362
|
-
|
|
3161
|
+
// 4. 自适应颜色生成
|
|
3162
|
+
const generateColors = (count) => {
|
|
3163
|
+
const presets = [
|
|
3164
|
+
'#5470c6',
|
|
3165
|
+
'#91cc75',
|
|
3166
|
+
'#fac858',
|
|
3167
|
+
'#ee6666',
|
|
3168
|
+
'#73c0de',
|
|
3169
|
+
'#3ba272',
|
|
3170
|
+
'#fc8452',
|
|
3171
|
+
'#9a60b4',
|
|
3172
|
+
'#ea7ccc'
|
|
3173
|
+
]
|
|
3174
|
+
if (count <= presets.length) return presets.slice(0, count)
|
|
2363
3175
|
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
if (top.max > prevMax) trend = '较上月更晚'
|
|
2370
|
-
else if (top.max < prevMax) trend = '较上月提前'
|
|
2371
|
-
else trend = '与上月持平'
|
|
2372
|
-
}
|
|
2373
|
-
lines.push(
|
|
2374
|
-
`${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend}。`
|
|
2375
|
-
)
|
|
2376
|
-
if (top.max >= 2) {
|
|
2377
|
-
lines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
|
|
2378
|
-
} else if (top.max >= 1) {
|
|
2379
|
-
lines.push('已超过 1 小时,注意控制夜间工作时长。')
|
|
2380
|
-
}
|
|
3176
|
+
return chartData.map((_, i) => {
|
|
3177
|
+
if (i < presets.length) return presets[i]
|
|
3178
|
+
// 超过预设后,动态生成 HSL 颜色
|
|
3179
|
+
return `hsl(${(i * 137.5) % 360}, 60%, 65%)` // 使用黄金角度 137.5 确保颜色分布均匀
|
|
3180
|
+
})
|
|
2381
3181
|
}
|
|
2382
3182
|
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
</div>
|
|
2393
|
-
`
|
|
3183
|
+
// 5. 调用现有绘图方法
|
|
3184
|
+
return drawPieWithTotal({
|
|
3185
|
+
el: 'authorTotalOvertimeRankSummary',
|
|
3186
|
+
title: '加班总时长排名分布',
|
|
3187
|
+
unit: '小时',
|
|
3188
|
+
totalLabel: '总时长',
|
|
3189
|
+
data: chartData,
|
|
3190
|
+
colors: generateColors(chartData.length)
|
|
3191
|
+
})
|
|
2394
3192
|
}
|
|
2395
3193
|
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
3194
|
+
// 渲染累计加班排名(chart 下方)
|
|
3195
|
+
function renderAuthorTotalOvertimeRankFromDs(ds, topN = 20) {
|
|
3196
|
+
const box = document.getElementById('authorTotalOvertimeRank')
|
|
3197
|
+
if (!box) return
|
|
3198
|
+
|
|
3199
|
+
if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) {
|
|
3200
|
+
box.innerHTML = '<div style="color:#777">暂无加班时长数据</div>'
|
|
3201
|
+
return
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
// 1. 建立索引映射 (O(n) 性能优化)
|
|
3205
|
+
const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
|
|
3206
|
+
|
|
3207
|
+
const totals = ds.authors.map((author) => {
|
|
3208
|
+
const data = seriesMap.get(author)
|
|
3209
|
+
const total = Array.isArray(data)
|
|
3210
|
+
? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
|
|
3211
|
+
: 0
|
|
3212
|
+
return { author, total }
|
|
3213
|
+
})
|
|
3214
|
+
|
|
3215
|
+
// 2. 排序
|
|
3216
|
+
totals.sort(
|
|
3217
|
+
(x, y) =>
|
|
3218
|
+
y.total - x.total || String(x.author).localeCompare(String(y.author))
|
|
3219
|
+
)
|
|
3220
|
+
|
|
3221
|
+
// 3. 处理 topN 为 0 输出全部的逻辑
|
|
3222
|
+
const top = topN > 0 ? totals.slice(0, topN) : totals
|
|
3223
|
+
const count = top.length
|
|
3224
|
+
|
|
3225
|
+
// 4. 动态生成颜色函数 (自适应任意长度)
|
|
3226
|
+
const getColor = (index, totalCount) => {
|
|
3227
|
+
const presetColors = [
|
|
3228
|
+
'#1976d2',
|
|
3229
|
+
'#00a76f',
|
|
3230
|
+
'#fb8c00',
|
|
3231
|
+
'#d32f2f',
|
|
3232
|
+
'#6a1b9a',
|
|
3233
|
+
'#00897b',
|
|
3234
|
+
'#ef5350',
|
|
3235
|
+
'#ffa000',
|
|
3236
|
+
'#5c6bc0',
|
|
3237
|
+
'#43a047'
|
|
3238
|
+
]
|
|
3239
|
+
|
|
3240
|
+
if (index < presetColors.length && totalCount <= presetColors.length) {
|
|
3241
|
+
return presetColors[index]
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
const hue = (index * (360 / totalCount) + 200) % 360
|
|
3245
|
+
return `hsl(${hue}, 65%, 50%)`
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
const safeEscape = (str) =>
|
|
3249
|
+
typeof escapeHtml === 'function'
|
|
3250
|
+
? escapeHtml(str)
|
|
3251
|
+
: String(str).replace(
|
|
3252
|
+
/[&<>"']/g,
|
|
3253
|
+
(m) =>
|
|
3254
|
+
({
|
|
3255
|
+
'&': '&',
|
|
3256
|
+
'<': '<',
|
|
3257
|
+
'>': '>',
|
|
3258
|
+
'"': '"',
|
|
3259
|
+
"'": '''
|
|
3260
|
+
})[m]
|
|
3261
|
+
)
|
|
3262
|
+
|
|
3263
|
+
// 5. 计算统计区间(显示总体覆盖范围)
|
|
3264
|
+
let rangeStr = ''
|
|
3265
|
+
if (Array.isArray(ds.allPeriods) && ds.allPeriods.length) {
|
|
3266
|
+
const first = ds.allPeriods[0]
|
|
3267
|
+
const last = ds.allPeriods[ds.allPeriods.length - 1]
|
|
3268
|
+
const activeType =
|
|
3269
|
+
document.querySelector('#tabsTotalOvertime button.active')?.dataset
|
|
3270
|
+
.type || 'daily'
|
|
3271
|
+
|
|
3272
|
+
const periodToDate = (p, type) => {
|
|
3273
|
+
if (type === 'daily') return p
|
|
3274
|
+
if (type === 'weekly' && p.includes('-W')) {
|
|
3275
|
+
const [yy, ww] = p.split('-W')
|
|
3276
|
+
const r = getISOWeekRange(Number(yy), Number(ww))
|
|
3277
|
+
return `${r.start} ~ ${r.end}`
|
|
3278
|
+
}
|
|
3279
|
+
if (type === 'monthly') {
|
|
3280
|
+
const [y, m] = p.split('-')
|
|
3281
|
+
const start = `${y}-${m}-01`
|
|
3282
|
+
const end = new Date(Number(y), Number(m), 0)
|
|
3283
|
+
const endStr = formatDateYMD(end)
|
|
3284
|
+
return `${start} ~ ${endStr}`
|
|
3285
|
+
}
|
|
3286
|
+
if (type === 'yearly') return `${p}-01-01 ~ ${p}-12-31`
|
|
3287
|
+
return p
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
try {
|
|
3291
|
+
const firstRange = periodToDate(first, activeType)
|
|
3292
|
+
const lastRange = periodToDate(last, activeType)
|
|
3293
|
+
// If daily, show start ~ end concisely
|
|
3294
|
+
if (activeType === 'daily')
|
|
3295
|
+
rangeStr = `统计区间:${firstRange} ~ ${lastRange}`
|
|
3296
|
+
else rangeStr = `统计区间:${firstRange} —— ${lastRange}`
|
|
3297
|
+
rangeStr = `<div style="color:#666;margin-bottom:6px;font-size:13px">${rangeStr}</div>`
|
|
3298
|
+
} catch (e) {
|
|
3299
|
+
rangeStr = ''
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
// 6. 奖牌与序号逻辑
|
|
3304
|
+
const medal = (i) =>
|
|
3305
|
+
i === 0 ? '🥇 ' : i === 1 ? '🥈 ' : i === 2 ? '🥉 ' : `${i + 1}. `
|
|
3306
|
+
|
|
3307
|
+
// Prefer ds.totals if available
|
|
3308
|
+
let totalsList = Array.isArray(ds.totals)
|
|
3309
|
+
? ds.totals.map((t) => ({
|
|
3310
|
+
author: t.author,
|
|
3311
|
+
totalHours: t.totalHours,
|
|
3312
|
+
days: t.days,
|
|
3313
|
+
avg: t.avg
|
|
3314
|
+
}))
|
|
3315
|
+
: null
|
|
3316
|
+
if (!totalsList) {
|
|
3317
|
+
const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
|
|
3318
|
+
totalsList = ds.authors.map((author) => {
|
|
3319
|
+
const data = seriesMap.get(author)
|
|
3320
|
+
const total = Array.isArray(data)
|
|
3321
|
+
? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
|
|
3322
|
+
: 0
|
|
3323
|
+
return { author, totalHours: Number(total.toFixed(2)), days: 0, avg: 0 }
|
|
3324
|
+
})
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
// 7. 渲染页面(header + 列表),并显示占用天数 & 平均小时,title hover 提示
|
|
3328
|
+
const listHtml = count
|
|
3329
|
+
? top
|
|
3330
|
+
.map((t, i) => {
|
|
3331
|
+
const info = totalsList.find((x) => x.author === t.author) || {}
|
|
3332
|
+
const total = Number((info.totalHours ?? t.total ?? 0).toFixed(2))
|
|
3333
|
+
const days = info.days ?? 0
|
|
3334
|
+
const avg =
|
|
3335
|
+
info.avg ?? (days > 0 ? Number((total / days).toFixed(2)) : 0)
|
|
3336
|
+
const title = `累计时长:${total} 小时\n占用天数:${days} 天\n平均每日:${avg} 小时`
|
|
3337
|
+
return `
|
|
3338
|
+
<div class="rank-item" title="${title}" style="display: flex; align-items: center; margin-bottom: 8px;">
|
|
3339
|
+
<span class="dot" style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; background:${getColor(i, count)}"></span>
|
|
3340
|
+
<span class="author" style="flex: 1;">
|
|
3341
|
+
${medal(i)}${safeEscape(t.author)}
|
|
3342
|
+
<div style="font-size:12px;color:#666;margin-top:2px">占用天数: ${days} 天 · 平均: ${avg} 小时</div>
|
|
3343
|
+
</span>
|
|
3344
|
+
<span class="hours" style="font-weight: bold;">${Number(total).toFixed(2)} 小时</span>
|
|
3345
|
+
</div>
|
|
3346
|
+
`
|
|
3347
|
+
})
|
|
3348
|
+
.join('')
|
|
3349
|
+
: '<div style="color:#777">暂无加班时长数据</div>'
|
|
3350
|
+
|
|
3351
|
+
box.innerHTML = `${rangeStr}${listHtml}`
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
// ====== 开发者 累计提交Changed(按日/周/月/年累计 Changed 行数) ======
|
|
3355
|
+
function buildAuthorTotalCommitsChangedDataset(commits, type) {
|
|
3356
|
+
const byAuthorPeriod = new Map()
|
|
3357
|
+
const periods = new Set()
|
|
3358
|
+
|
|
3359
|
+
commits.forEach((c) => {
|
|
3360
|
+
const author = c.author || 'unknown'
|
|
3361
|
+
const changed = Number(c.changed) || 0
|
|
3362
|
+
const d = new Date(c.date)
|
|
3363
|
+
if (Number.isNaN(d.valueOf())) return
|
|
3364
|
+
|
|
3365
|
+
let period
|
|
3366
|
+
if (type === 'daily') period = d.toISOString().slice(0, 10)
|
|
3367
|
+
else if (type === 'weekly') period = getIsoWeekKey(d.toISOString())
|
|
3368
|
+
else if (type === 'monthly')
|
|
3369
|
+
period = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
3370
|
+
else if (type === 'yearly') period = String(d.getFullYear())
|
|
3371
|
+
else period = d.toISOString().slice(0, 10)
|
|
3372
|
+
|
|
3373
|
+
periods.add(period)
|
|
3374
|
+
if (!byAuthorPeriod.has(author)) byAuthorPeriod.set(author, {})
|
|
3375
|
+
const obj = byAuthorPeriod.get(author)
|
|
3376
|
+
obj[period] = (obj[period] || 0) + changed
|
|
3377
|
+
})
|
|
3378
|
+
|
|
3379
|
+
const allPeriods = Array.from(periods).sort()
|
|
3380
|
+
const authors = Array.from(byAuthorPeriod.keys()).sort()
|
|
3381
|
+
const series = authors.map((a) => ({
|
|
3382
|
+
name: a,
|
|
3383
|
+
type: 'line',
|
|
3384
|
+
smooth: true,
|
|
3385
|
+
data: allPeriods.map((p) =>
|
|
3386
|
+
Number((byAuthorPeriod.get(a)[p] || 0).toFixed(2))
|
|
3387
|
+
)
|
|
3388
|
+
}))
|
|
3389
|
+
|
|
3390
|
+
const totals = authors.map((a) => {
|
|
3391
|
+
const periodObj = byAuthorPeriod.get(a) || {}
|
|
3392
|
+
const totalChanged = Object.values(periodObj).reduce(
|
|
3393
|
+
(s, v) => s + (Number(v) || 0),
|
|
3394
|
+
0
|
|
3395
|
+
)
|
|
3396
|
+
const days = Object.keys(periodObj).length
|
|
3397
|
+
const avg = days > 0 ? Number((totalChanged / days).toFixed(2)) : 0
|
|
3398
|
+
return {
|
|
3399
|
+
author: a,
|
|
3400
|
+
totalChanged: Number(totalChanged.toFixed(2)),
|
|
3401
|
+
days,
|
|
3402
|
+
avg
|
|
3403
|
+
}
|
|
3404
|
+
})
|
|
3405
|
+
|
|
3406
|
+
return { authors, allPeriods, series, totals }
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
function drawAuthorTotalCommitsChangedTrends(commits) {
|
|
3410
|
+
const el = document.getElementById('chartAuthorTotalCommitsChanged')
|
|
3411
|
+
if (!el) return null
|
|
3412
|
+
// FIXME: remove debug log before production
|
|
3413
|
+
console.log('❌', 'window.__config', window.__config)
|
|
3414
|
+
if (!window.__config.git.numstat) {
|
|
3415
|
+
|
|
3416
|
+
document.getElementById('totalCommitsChangedCard').style.display = 'none'
|
|
3417
|
+
document.getElementById('totalCommitsChangedRankSection').style.display = 'none'
|
|
3418
|
+
return null
|
|
3419
|
+
}
|
|
3420
|
+
const chart = echarts.init(el)
|
|
3421
|
+
chartInstances.push(chart)
|
|
3422
|
+
|
|
3423
|
+
function render(type) {
|
|
3424
|
+
const ds = buildAuthorTotalCommitsChangedDataset(commits, type)
|
|
3425
|
+
chart.setOption({
|
|
3426
|
+
tooltip: {
|
|
3427
|
+
trigger: 'axis',
|
|
3428
|
+
formatter(params) {
|
|
3429
|
+
if (!params || !params.length) return ''
|
|
3430
|
+
const label = params[0].axisValue
|
|
3431
|
+
const lines = params
|
|
3432
|
+
.filter((i) => i.data > 0)
|
|
3433
|
+
.sort((a, b) => (b.data || 0) - (a.data || 0))
|
|
3434
|
+
.map((item) => `${item.marker}${item.seriesName}: ${item.data} 行`)
|
|
3435
|
+
.join('<br/>')
|
|
3436
|
+
return `<div>${label}</div>${lines}`
|
|
3437
|
+
}
|
|
3438
|
+
},
|
|
3439
|
+
legend: { data: ds.authors },
|
|
3440
|
+
xAxis: { type: 'category', data: ds.allPeriods },
|
|
3441
|
+
yAxis: { type: 'value', name: 'Changed 行数' },
|
|
3442
|
+
series: ds.series
|
|
3443
|
+
})
|
|
3444
|
+
|
|
3445
|
+
try {
|
|
3446
|
+
renderAuthorTotalCommitsChangedRankFromDs(ds, 0)
|
|
3447
|
+
renderAuthorTotalCommitsChangedRank(ds, 0)
|
|
3448
|
+
} catch (e) {
|
|
3449
|
+
console.warn('更新累计提交Changed排名失败', e)
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
render('daily')
|
|
3454
|
+
|
|
3455
|
+
const tabs = document.querySelectorAll('#tabsTotalCommitsChanged button')
|
|
3456
|
+
tabs.forEach((btnEl) => {
|
|
3457
|
+
btnEl.addEventListener('click', () => {
|
|
3458
|
+
tabs.forEach((b) => b.classList.remove('active'))
|
|
3459
|
+
btnEl.classList.add('active')
|
|
3460
|
+
render(btnEl.dataset.type)
|
|
3461
|
+
})
|
|
3462
|
+
})
|
|
3463
|
+
|
|
3464
|
+
chart.on('click', (p) => {
|
|
3465
|
+
try {
|
|
3466
|
+
if (!p || p.componentType !== 'series') return
|
|
3467
|
+
const label = p.axisValue || p.name
|
|
3468
|
+
const author = p.seriesName
|
|
3469
|
+
if (!label || !author) return
|
|
3470
|
+
const type =
|
|
3471
|
+
document.querySelector('#tabsTotalCommitsChanged button.active')
|
|
3472
|
+
?.dataset.type || 'daily'
|
|
3473
|
+
|
|
3474
|
+
const filteredCommits = commits.filter((c) => {
|
|
3475
|
+
const a = c.author || 'unknown'
|
|
3476
|
+
if (a !== author) return false
|
|
3477
|
+
const d = new Date(c.date)
|
|
3478
|
+
if (Number.isNaN(d.valueOf())) return false
|
|
3479
|
+
|
|
3480
|
+
if (type === 'daily') return d.toISOString().slice(0, 10) === label
|
|
3481
|
+
if (type === 'weekly') {
|
|
3482
|
+
if (!label.includes('-W')) return false
|
|
3483
|
+
const [yy, ww] = label.split('-W')
|
|
3484
|
+
const range = getISOWeekRange(Number(yy), Number(ww))
|
|
3485
|
+
const day = d.toISOString().slice(0, 10)
|
|
3486
|
+
return day >= range.start && day <= range.end
|
|
3487
|
+
}
|
|
3488
|
+
if (type === 'monthly') {
|
|
3489
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
3490
|
+
return month === label
|
|
3491
|
+
}
|
|
3492
|
+
const year = String(d.getFullYear())
|
|
3493
|
+
return year === label
|
|
3494
|
+
})
|
|
3495
|
+
|
|
3496
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
3497
|
+
|
|
3498
|
+
if (type === 'weekly') {
|
|
3499
|
+
const weeklyItem = {
|
|
3500
|
+
outsideWorkCount: filteredCommits.length,
|
|
3501
|
+
outsideWorkRate: 0
|
|
3502
|
+
}
|
|
3503
|
+
showSideBarForWeek({
|
|
3504
|
+
period: label,
|
|
3505
|
+
weeklyItem,
|
|
3506
|
+
commits: filteredCommits,
|
|
3507
|
+
titleDrawer: `${author} Changed 本周详情`
|
|
3508
|
+
})
|
|
3509
|
+
} else {
|
|
3510
|
+
showDayDetailSidebar({
|
|
3511
|
+
date: label,
|
|
3512
|
+
count: filteredCommits.length,
|
|
3513
|
+
commits: filteredCommits,
|
|
3514
|
+
titleDrawer: `${author} Changed ${type} 详情`
|
|
3515
|
+
})
|
|
3516
|
+
}
|
|
3517
|
+
} catch (err) {
|
|
3518
|
+
console.warn('Total commits changed chart click handler error', err)
|
|
3519
|
+
}
|
|
3520
|
+
})
|
|
3521
|
+
|
|
3522
|
+
return chart
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
function renderAuthorTotalCommitsChangedRank(ds, topN = 10) {
|
|
3526
|
+
const el = document.getElementById('authorTotalCommitsChangedRankSummary')
|
|
3527
|
+
|
|
3528
|
+
if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) return
|
|
3529
|
+
const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
|
|
3530
|
+
const totals = ds.authors.map((author) => {
|
|
3531
|
+
const data = seriesMap.get(author)
|
|
3532
|
+
const total = Array.isArray(data)
|
|
3533
|
+
? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
|
|
3534
|
+
: 0
|
|
3535
|
+
return { name: author, value: Number(total.toFixed(2)) }
|
|
3536
|
+
})
|
|
3537
|
+
totals.sort((a, b) => b.value - a.value)
|
|
3538
|
+
|
|
3539
|
+
let chartData = []
|
|
3540
|
+
if (topN > 0 && totals.length > topN) {
|
|
3541
|
+
chartData = totals.slice(0, topN)
|
|
3542
|
+
const othersValue = totals
|
|
3543
|
+
.slice(topN)
|
|
3544
|
+
.reduce((sum, item) => sum + item.value, 0)
|
|
3545
|
+
chartData.push({ name: '其他', value: Number(othersValue.toFixed(2)) })
|
|
3546
|
+
} else chartData = totals
|
|
3547
|
+
|
|
3548
|
+
// 4. 自适应颜色生成
|
|
3549
|
+
const generateColors = (count) => {
|
|
3550
|
+
const presets = [
|
|
3551
|
+
'#5470c6',
|
|
3552
|
+
'#91cc75',
|
|
3553
|
+
'#fac858',
|
|
3554
|
+
'#ee6666',
|
|
3555
|
+
'#73c0de',
|
|
3556
|
+
'#3ba272',
|
|
3557
|
+
'#fc8452',
|
|
3558
|
+
'#9a60b4',
|
|
3559
|
+
'#ea7ccc'
|
|
3560
|
+
]
|
|
3561
|
+
if (count <= presets.length) return presets.slice(0, count)
|
|
3562
|
+
|
|
3563
|
+
return chartData.map((_, i) => {
|
|
3564
|
+
if (i < presets.length) return presets[i]
|
|
3565
|
+
// 超过预设后,动态生成 HSL 颜色
|
|
3566
|
+
return `hsl(${(i * 137.5) % 360}, 60%, 65%)` // 使用黄金角度 137.5 确保颜色分布均匀
|
|
3567
|
+
})
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
return drawPieWithTotal({
|
|
3571
|
+
el,
|
|
3572
|
+
title: '提交Changed排名分布',
|
|
3573
|
+
unit: '行',
|
|
3574
|
+
totalLabel: '总行数',
|
|
3575
|
+
data: chartData,
|
|
3576
|
+
colors: generateColors(chartData.length)
|
|
3577
|
+
})
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
function renderAuthorTotalCommitsChangedRankFromDs(ds, topN = 20) {
|
|
3581
|
+
const box = document.getElementById('authorTotalCommitsChangedRank')
|
|
3582
|
+
if (!box) return
|
|
3583
|
+
if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) {
|
|
3584
|
+
box.innerHTML = '<div style="color:#777">暂无提交Changed数据</div>'
|
|
3585
|
+
return
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
|
|
3589
|
+
const totals = ds.authors.map((author) => {
|
|
3590
|
+
const data = seriesMap.get(author) || []
|
|
3591
|
+
const total = Array.isArray(data)
|
|
3592
|
+
? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
|
|
3593
|
+
: 0
|
|
3594
|
+
return { author, total }
|
|
3595
|
+
})
|
|
3596
|
+
|
|
3597
|
+
totals.sort(
|
|
3598
|
+
(x, y) =>
|
|
3599
|
+
y.total - x.total || String(x.author).localeCompare(String(y.author))
|
|
3600
|
+
)
|
|
3601
|
+
|
|
3602
|
+
const top = topN > 0 ? totals.slice(0, topN) : totals
|
|
3603
|
+
const count = top.length
|
|
3604
|
+
|
|
3605
|
+
const getColor = (index, totalCount) => {
|
|
3606
|
+
const presetColors = [
|
|
3607
|
+
'#1976d2',
|
|
3608
|
+
'#00a76f',
|
|
3609
|
+
'#fb8c00',
|
|
3610
|
+
'#d32f2f',
|
|
3611
|
+
'#6a1b9a',
|
|
3612
|
+
'#00897b',
|
|
3613
|
+
'#ef5350',
|
|
3614
|
+
'#ffa000',
|
|
3615
|
+
'#5c6bc0',
|
|
3616
|
+
'#43a047'
|
|
3617
|
+
]
|
|
3618
|
+
if (index < presetColors.length && totalCount <= presetColors.length)
|
|
3619
|
+
return presetColors[index]
|
|
3620
|
+
const hue = (index * (360 / totalCount) + 200) % 360
|
|
3621
|
+
return `hsl(${hue}, 65%, 50%)`
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
const safeEscape = (str) =>
|
|
3625
|
+
typeof escapeHtml === 'function' ? escapeHtml(str) : String(str)
|
|
3626
|
+
|
|
3627
|
+
let rangeStr = ''
|
|
3628
|
+
if (Array.isArray(ds.allPeriods) && ds.allPeriods.length) {
|
|
3629
|
+
const first = ds.allPeriods[0]
|
|
3630
|
+
const last = ds.allPeriods[ds.allPeriods.length - 1]
|
|
3631
|
+
const activeType =
|
|
3632
|
+
document.querySelector('#tabsTotalCommitsChanged button.active')?.dataset
|
|
3633
|
+
.type || 'daily'
|
|
3634
|
+
|
|
3635
|
+
const periodToDate = (p, type) => {
|
|
3636
|
+
if (type === 'daily') return p
|
|
3637
|
+
if (type === 'weekly' && p.includes('-W')) {
|
|
3638
|
+
const [yy, ww] = p.split('-W')
|
|
3639
|
+
const r = getISOWeekRange(Number(yy), Number(ww))
|
|
3640
|
+
return `${r.start} ~ ${r.end}`
|
|
3641
|
+
}
|
|
3642
|
+
if (type === 'monthly') {
|
|
3643
|
+
const [y, m] = p.split('-')
|
|
3644
|
+
const start = `${y}-${m}-01`
|
|
3645
|
+
const end = new Date(Number(y), Number(m), 0)
|
|
3646
|
+
const endStr = formatDateYMD(end)
|
|
3647
|
+
return `${start} ~ ${endStr}`
|
|
3648
|
+
}
|
|
3649
|
+
if (type === 'yearly') return `${p}-01-01 ~ ${p}-12-31`
|
|
3650
|
+
return p
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
try {
|
|
3654
|
+
const firstRange = periodToDate(first, activeType)
|
|
3655
|
+
const lastRange = periodToDate(last, activeType)
|
|
3656
|
+
if (activeType === 'daily')
|
|
3657
|
+
rangeStr = `统计区间:${firstRange} ~ ${lastRange}`
|
|
3658
|
+
else rangeStr = `统计区间:${firstRange} —— ${lastRange}`
|
|
3659
|
+
rangeStr = `<div style="color:#666;margin-bottom:6px;font-size:13px">${rangeStr}</div>`
|
|
3660
|
+
} catch (e) {
|
|
3661
|
+
rangeStr = ''
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
const medal = (i) =>
|
|
3666
|
+
i === 0 ? '🥇 ' : i === 1 ? '🥈 ' : i === 2 ? '🥉 ' : `${i + 1}. `
|
|
3667
|
+
|
|
3668
|
+
let totalsList = Array.isArray(ds.totals)
|
|
3669
|
+
? ds.totals.map((t) => ({
|
|
3670
|
+
author: t.author,
|
|
3671
|
+
totalChanged: t.totalChanged,
|
|
3672
|
+
days: t.days,
|
|
3673
|
+
avg: t.avg
|
|
3674
|
+
}))
|
|
3675
|
+
: null
|
|
3676
|
+
if (!totalsList) {
|
|
3677
|
+
totalsList = totals.map((t) => ({
|
|
3678
|
+
author: t.author,
|
|
3679
|
+
totalChanged: Number((t.total || 0).toFixed(2)),
|
|
3680
|
+
days: 0,
|
|
3681
|
+
avg: 0
|
|
3682
|
+
}))
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
const listHtml = count
|
|
3686
|
+
? top
|
|
3687
|
+
.map((t, i) => {
|
|
3688
|
+
const info = totalsList.find((x) => x.author === t.author) || {}
|
|
3689
|
+
const total = Number((info.totalChanged ?? t.total ?? 0).toFixed(2))
|
|
3690
|
+
const days = info.days ?? 0
|
|
3691
|
+
const avg =
|
|
3692
|
+
info.avg ?? (days > 0 ? Number((total / days).toFixed(2)) : 0)
|
|
3693
|
+
const title = `累计Changed:${total} 行\n占用天数:${days} 天\n平均每日:${avg} 行`
|
|
3694
|
+
return `
|
|
3695
|
+
<div class="rank-item" title="${title}" style="display: flex; align-items: center; margin-bottom: 8px;">
|
|
3696
|
+
<span class="dot" style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; background:${getColor(i, count)}"></span>
|
|
3697
|
+
<span class="author" style="flex: 1;">
|
|
3698
|
+
${medal(i)}${safeEscape(t.author)}
|
|
3699
|
+
<div style="font-size:12px;color:#666;margin-top:2px">占用天数: ${days} 天 · 平均: ${avg} 行</div>
|
|
3700
|
+
</span>
|
|
3701
|
+
<span class="hours" style="font-weight: bold;">${Number(total).toFixed(2)} 行</span>
|
|
3702
|
+
</div>
|
|
3703
|
+
`
|
|
3704
|
+
})
|
|
3705
|
+
.join('')
|
|
3706
|
+
: '<div style="color:#777">暂无提交Changed数据</div>'
|
|
3707
|
+
|
|
3708
|
+
box.innerHTML = `${rangeStr}${listHtml}`
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
// 渲染作者午休累计时长分布饼图
|
|
3712
|
+
function renderAuthorTotalLunchTimeRank(ds, topN = 10) {
|
|
3713
|
+
if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) return
|
|
3714
|
+
const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
|
|
3715
|
+
const totals = ds.authors.map((author) => {
|
|
3716
|
+
const data = seriesMap.get(author)
|
|
3717
|
+
const total = Array.isArray(data)
|
|
3718
|
+
? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
|
|
3719
|
+
: 0
|
|
3720
|
+
return { name: author, value: Number(total.toFixed(2)) }
|
|
3721
|
+
})
|
|
3722
|
+
|
|
3723
|
+
// 4. 自适应颜色生成
|
|
3724
|
+
const generateColors = (count) => {
|
|
3725
|
+
const presets = [
|
|
3726
|
+
'#5470c6',
|
|
3727
|
+
'#91cc75',
|
|
3728
|
+
'#fac858',
|
|
3729
|
+
'#ee6666',
|
|
3730
|
+
'#73c0de',
|
|
3731
|
+
'#3ba272',
|
|
3732
|
+
'#fc8452',
|
|
3733
|
+
'#9a60b4',
|
|
3734
|
+
'#ea7ccc'
|
|
3735
|
+
]
|
|
3736
|
+
if (count <= presets.length) return presets.slice(0, count)
|
|
3737
|
+
|
|
3738
|
+
return totals.map((_, i) => {
|
|
3739
|
+
if (i < presets.length) return presets[i]
|
|
3740
|
+
// 超过预设后,动态生成 HSL 颜色
|
|
3741
|
+
return `hsl(${(i * 137.5) % 360}, 60%, 65%)` // 使用黄金角度 137.5 确保颜色分布均匀
|
|
3742
|
+
})
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
// TODO: remove debug log before production
|
|
3746
|
+
console.log('✅', 'totals', totals)
|
|
3747
|
+
return drawPieWithTotal({
|
|
3748
|
+
el: 'authorTotalLunchTimeRankSummary',
|
|
3749
|
+
title: '午休累计时长排名分布',
|
|
3750
|
+
unit: '小时',
|
|
3751
|
+
totalLabel: '总时长',
|
|
3752
|
+
data: totals,
|
|
3753
|
+
colors: generateColors(totals.length)
|
|
3754
|
+
})
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
// 渲染午休累计排名(chart 下方),含序号/奖牌与统计区间
|
|
3758
|
+
function renderAuthorTotalLunchTimeRankFromDs(ds, topN = 20) {
|
|
3759
|
+
const box = document.getElementById('authorTotalLunchTimeRank')
|
|
3760
|
+
if (!box) return
|
|
3761
|
+
|
|
3762
|
+
if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) {
|
|
3763
|
+
box.innerHTML = '<div style="color:#777">暂无午休累计时长数据</div>'
|
|
3764
|
+
return
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3767
|
+
// Prefer ds.totals (contains days/avg) if available, otherwise compute totals from series
|
|
3768
|
+
let totals = Array.isArray(ds.totals)
|
|
3769
|
+
? ds.totals.map((t) => ({
|
|
3770
|
+
author: t.author,
|
|
3771
|
+
totalHours: t.totalHours,
|
|
3772
|
+
days: t.days,
|
|
3773
|
+
avg: t.avg
|
|
3774
|
+
}))
|
|
3775
|
+
: null
|
|
3776
|
+
|
|
3777
|
+
if (!totals) {
|
|
3778
|
+
const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
|
|
3779
|
+
totals = ds.authors.map((author) => {
|
|
3780
|
+
const data = seriesMap.get(author) || []
|
|
3781
|
+
const total = Array.isArray(data)
|
|
3782
|
+
? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
|
|
3783
|
+
: 0
|
|
3784
|
+
return { author, totalHours: Number(total.toFixed(2)), days: 0, avg: 0 }
|
|
3785
|
+
})
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
totals.sort(
|
|
3789
|
+
(x, y) =>
|
|
3790
|
+
(y.totalHours || y.total || 0) - (x.totalHours || x.total || 0) ||
|
|
3791
|
+
String(x.author).localeCompare(String(y.author))
|
|
3792
|
+
)
|
|
3793
|
+
|
|
3794
|
+
const top = topN > 0 ? totals.slice(0, topN) : totals
|
|
3795
|
+
const count = top.length
|
|
3796
|
+
|
|
3797
|
+
const getColor = (index, totalCount) => {
|
|
3798
|
+
const presetColors = [
|
|
3799
|
+
'#1976d2',
|
|
3800
|
+
'#00a76f',
|
|
3801
|
+
'#fb8c00',
|
|
3802
|
+
'#d32f2f',
|
|
3803
|
+
'#6a1b9a',
|
|
3804
|
+
'#00897b',
|
|
3805
|
+
'#ef5350',
|
|
3806
|
+
'#ffa000',
|
|
3807
|
+
'#5c6bc0',
|
|
3808
|
+
'#43a047'
|
|
3809
|
+
]
|
|
3810
|
+
if (index < presetColors.length && totalCount <= presetColors.length)
|
|
3811
|
+
return presetColors[index]
|
|
3812
|
+
const hue = (index * (360 / totalCount) + 200) % 360
|
|
3813
|
+
return `hsl(${hue}, 65%, 50%)`
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3816
|
+
const safeEscape = (str) =>
|
|
3817
|
+
typeof escapeHtml === 'function'
|
|
3818
|
+
? escapeHtml(str)
|
|
3819
|
+
: String(str).replace(
|
|
3820
|
+
/[&<>"']/g,
|
|
3821
|
+
(m) =>
|
|
3822
|
+
({
|
|
3823
|
+
'&': '&',
|
|
3824
|
+
'<': '<',
|
|
3825
|
+
'>': '>',
|
|
3826
|
+
'"': '"',
|
|
3827
|
+
"'": '''
|
|
3828
|
+
})[m]
|
|
3829
|
+
)
|
|
3830
|
+
|
|
3831
|
+
let rangeStr = ''
|
|
3832
|
+
if (Array.isArray(ds.allPeriods) && ds.allPeriods.length) {
|
|
3833
|
+
const first = ds.allPeriods[0]
|
|
3834
|
+
const last = ds.allPeriods[ds.allPeriods.length - 1]
|
|
3835
|
+
const activeType =
|
|
3836
|
+
document.querySelector('#tabsTotalLunchTime button.active')?.dataset
|
|
3837
|
+
.type || 'daily'
|
|
3838
|
+
|
|
3839
|
+
const periodToDate = (p, type) => {
|
|
3840
|
+
if (type === 'daily') return p
|
|
3841
|
+
if (type === 'weekly' && p.includes('-W')) {
|
|
3842
|
+
const [yy, ww] = p.split('-W')
|
|
3843
|
+
const r = getISOWeekRange(Number(yy), Number(ww))
|
|
3844
|
+
return `${r.start} ~ ${r.end}`
|
|
3845
|
+
}
|
|
3846
|
+
if (type === 'monthly') {
|
|
3847
|
+
const [y, m] = p.split('-')
|
|
3848
|
+
const start = `${y}-${m}-01`
|
|
3849
|
+
const end = new Date(Number(y), Number(m), 0)
|
|
3850
|
+
const endStr = formatDateYMD(end)
|
|
3851
|
+
return `${start} ~ ${endStr}`
|
|
3852
|
+
}
|
|
3853
|
+
if (type === 'yearly') return `${p}-01-01 ~ ${p}-12-31`
|
|
3854
|
+
return p
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3857
|
+
try {
|
|
3858
|
+
const firstRange = periodToDate(first, activeType)
|
|
3859
|
+
const lastRange = periodToDate(last, activeType)
|
|
3860
|
+
if (activeType === 'daily')
|
|
3861
|
+
rangeStr = `统计区间:${firstRange} ~ ${lastRange}`
|
|
3862
|
+
else rangeStr = `统计区间:${firstRange} —— ${lastRange}`
|
|
3863
|
+
rangeStr = `<div style="color:#666;margin-bottom:6px;font-size:13px">${rangeStr}</div>`
|
|
3864
|
+
} catch (e) {
|
|
3865
|
+
rangeStr = ''
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
const medal = (i) =>
|
|
3870
|
+
i === 0 ? '🥇 ' : i === 1 ? '🥈 ' : i === 2 ? '🥉 ' : `${i + 1}. `
|
|
3871
|
+
|
|
3872
|
+
const listHtml = count
|
|
3873
|
+
? top
|
|
3874
|
+
.map((t, i) => {
|
|
3875
|
+
const total = Number((t.totalHours ?? t.total ?? 0).toFixed(2))
|
|
3876
|
+
const days = t.days ?? 0
|
|
3877
|
+
const avg =
|
|
3878
|
+
t.avg ?? (days > 0 ? Number((total / days).toFixed(2)) : 0)
|
|
3879
|
+
const title = `累计时长:${total} 小时\n占用天数:${days} 天\n平均每日:${avg} 小时`
|
|
3880
|
+
return `
|
|
3881
|
+
<div class="rank-item" title="${title}" style="display: flex; align-items: center; margin-bottom: 8px;">
|
|
3882
|
+
<span class="dot" style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; background:${getColor(i, count)}"></span>
|
|
3883
|
+
<span class="author" style="flex: 1;">
|
|
3884
|
+
${medal(i)}${safeEscape(t.author)}
|
|
3885
|
+
<div style="font-size:12px;color:#666;margin-top:2px">占用天数: ${days} 天 · 平均: ${avg} 小时</div>
|
|
3886
|
+
</span>
|
|
3887
|
+
<span class="hours" style="font-weight: bold;">${Number(total).toFixed(2)} 小时</span>
|
|
3888
|
+
</div>
|
|
3889
|
+
`
|
|
3890
|
+
})
|
|
3891
|
+
.join('')
|
|
3892
|
+
: '<div style="color:#777">暂无午休累计时长数据</div>'
|
|
3893
|
+
|
|
3894
|
+
box.innerHTML = `${rangeStr}${listHtml}`
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3897
|
+
// ====== 开发者 累计提交次数(按日/周/月/年累计提交数) ======
|
|
3898
|
+
function buildAuthorTotalCommitsDataset(commits, type) {
|
|
3899
|
+
const byAuthorPeriod = new Map()
|
|
3900
|
+
const periods = new Set()
|
|
3901
|
+
|
|
3902
|
+
commits.forEach((c) => {
|
|
3903
|
+
const author = c.author || 'unknown'
|
|
3904
|
+
const d = new Date(c.date)
|
|
3905
|
+
if (Number.isNaN(d.valueOf())) return
|
|
3906
|
+
|
|
3907
|
+
let period
|
|
3908
|
+
if (type === 'daily') period = d.toISOString().slice(0, 10)
|
|
3909
|
+
else if (type === 'weekly') period = getIsoWeekKey(d.toISOString())
|
|
3910
|
+
else if (type === 'monthly')
|
|
3911
|
+
period = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
3912
|
+
else if (type === 'yearly') period = String(d.getFullYear())
|
|
3913
|
+
else period = d.toISOString().slice(0, 10)
|
|
3914
|
+
|
|
3915
|
+
periods.add(period)
|
|
3916
|
+
if (!byAuthorPeriod.has(author)) byAuthorPeriod.set(author, {})
|
|
3917
|
+
const obj = byAuthorPeriod.get(author)
|
|
3918
|
+
obj[period] = (obj[period] || 0) + 1
|
|
3919
|
+
})
|
|
3920
|
+
|
|
3921
|
+
const allPeriods = Array.from(periods).sort()
|
|
3922
|
+
const authors = Array.from(byAuthorPeriod.keys()).sort()
|
|
3923
|
+
const series = authors.map((a) => ({
|
|
3924
|
+
name: a,
|
|
3925
|
+
type: 'line',
|
|
3926
|
+
smooth: true,
|
|
3927
|
+
data: allPeriods.map((p) => byAuthorPeriod.get(a)[p] || 0)
|
|
3928
|
+
}))
|
|
3929
|
+
|
|
3930
|
+
const totals = authors.map((a) => {
|
|
3931
|
+
const periodObj = byAuthorPeriod.get(a) || {}
|
|
3932
|
+
const totalCommits = Object.values(periodObj).reduce(
|
|
3933
|
+
(s, v) => s + (Number(v) || 0),
|
|
3934
|
+
0
|
|
3935
|
+
)
|
|
3936
|
+
const days = Object.keys(periodObj).length
|
|
3937
|
+
const avg = days > 0 ? Number((totalCommits / days).toFixed(2)) : 0
|
|
3938
|
+
return { author: a, totalCommits, days, avg }
|
|
3939
|
+
})
|
|
3940
|
+
|
|
3941
|
+
return { authors, allPeriods, series, totals }
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
function drawAuthorTotalCommitsTrends(commits) {
|
|
3945
|
+
const el = document.getElementById('chartAuthorTotalCommits')
|
|
3946
|
+
if (!el) return null
|
|
3947
|
+
const chart = echarts.init(el)
|
|
3948
|
+
chartInstances.push(chart)
|
|
3949
|
+
|
|
3950
|
+
function render(type) {
|
|
3951
|
+
const ds = buildAuthorTotalCommitsDataset(commits, type)
|
|
3952
|
+
|
|
3953
|
+
chart.setOption({
|
|
3954
|
+
tooltip: {
|
|
3955
|
+
trigger: 'axis',
|
|
3956
|
+
formatter(params) {
|
|
3957
|
+
if (!params || !params.length) return ''
|
|
3958
|
+
const label = params[0].axisValue
|
|
3959
|
+
const lines = params
|
|
3960
|
+
.filter((i) => i.data > 0)
|
|
3961
|
+
.sort((a, b) => (b.data || 0) - (a.data || 0))
|
|
3962
|
+
.map((item) => `${item.marker}${item.seriesName}: ${item.data} 次`)
|
|
3963
|
+
.join('<br/>')
|
|
3964
|
+
return `<div>${label}</div>${lines}`
|
|
3965
|
+
}
|
|
3966
|
+
},
|
|
3967
|
+
legend: { data: ds.authors },
|
|
3968
|
+
xAxis: { type: 'category', data: ds.allPeriods },
|
|
3969
|
+
yAxis: { type: 'value', name: '提交次数' },
|
|
3970
|
+
series: ds.series
|
|
3971
|
+
})
|
|
3972
|
+
|
|
3973
|
+
try {
|
|
3974
|
+
renderAuthorTotalCommitsRankFromDs(ds, 0)
|
|
3975
|
+
renderAuthorTotalCommitsRank(ds, 0)
|
|
3976
|
+
} catch (e) {
|
|
3977
|
+
console.warn('更新累计提交排名失败', e)
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3981
|
+
render('daily')
|
|
3982
|
+
|
|
3983
|
+
const tabs = document.querySelectorAll('#tabsTotalCommits button')
|
|
3984
|
+
tabs.forEach((btnEl) => {
|
|
3985
|
+
btnEl.addEventListener('click', () => {
|
|
3986
|
+
tabs.forEach((b) => b.classList.remove('active'))
|
|
3987
|
+
btnEl.classList.add('active')
|
|
3988
|
+
render(btnEl.dataset.type)
|
|
3989
|
+
})
|
|
3990
|
+
})
|
|
3991
|
+
|
|
3992
|
+
chart.on('click', (p) => {
|
|
3993
|
+
try {
|
|
3994
|
+
if (!p || p.componentType !== 'series') return
|
|
3995
|
+
const label = p.axisValue || p.name
|
|
3996
|
+
const author = p.seriesName
|
|
3997
|
+
if (!label || !author) return
|
|
3998
|
+
const type =
|
|
3999
|
+
document.querySelector('#tabsTotalCommits button.active')?.dataset
|
|
4000
|
+
.type || 'daily'
|
|
4001
|
+
|
|
4002
|
+
const filteredCommits = commits.filter((c) => {
|
|
4003
|
+
const a = c.author || 'unknown'
|
|
4004
|
+
if (a !== author) return false
|
|
4005
|
+
const d = new Date(c.date)
|
|
4006
|
+
if (Number.isNaN(d.valueOf())) return false
|
|
4007
|
+
|
|
4008
|
+
if (type === 'daily') return d.toISOString().slice(0, 10) === label
|
|
4009
|
+
if (type === 'weekly') {
|
|
4010
|
+
if (!label.includes('-W')) return false
|
|
4011
|
+
const [yy, ww] = label.split('-W')
|
|
4012
|
+
const range = getISOWeekRange(Number(yy), Number(ww))
|
|
4013
|
+
const day = d.toISOString().slice(0, 10)
|
|
4014
|
+
return day >= range.start && day <= range.end
|
|
4015
|
+
}
|
|
4016
|
+
if (type === 'monthly') {
|
|
4017
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
4018
|
+
return month === label
|
|
4019
|
+
}
|
|
4020
|
+
const year = String(d.getFullYear())
|
|
4021
|
+
return year === label
|
|
4022
|
+
})
|
|
4023
|
+
|
|
4024
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
4025
|
+
|
|
4026
|
+
if (type === 'weekly') {
|
|
4027
|
+
const weeklyItem = {
|
|
4028
|
+
outsideWorkCount: filteredCommits.length,
|
|
4029
|
+
outsideWorkRate: 0
|
|
4030
|
+
}
|
|
4031
|
+
showSideBarForWeek({
|
|
4032
|
+
period: label,
|
|
4033
|
+
weeklyItem,
|
|
4034
|
+
commits: filteredCommits,
|
|
4035
|
+
titleDrawer: `${author} 提交 本周详情`
|
|
4036
|
+
})
|
|
4037
|
+
} else {
|
|
4038
|
+
showDayDetailSidebar({
|
|
4039
|
+
date: label,
|
|
4040
|
+
count: filteredCommits.length,
|
|
4041
|
+
commits: filteredCommits,
|
|
4042
|
+
titleDrawer: `${author} 提交 ${type} 详情`
|
|
4043
|
+
})
|
|
4044
|
+
}
|
|
4045
|
+
} catch (err) {
|
|
4046
|
+
console.warn('Total commits chart click handler error', err)
|
|
4047
|
+
}
|
|
4048
|
+
})
|
|
4049
|
+
|
|
4050
|
+
return chart
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
function renderAuthorTotalCommitsRank(ds, topN = 10) {
|
|
4054
|
+
if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) return
|
|
4055
|
+
const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
|
|
4056
|
+
const totals = ds.authors.map((author) => {
|
|
4057
|
+
const data = seriesMap.get(author)
|
|
4058
|
+
const total = Array.isArray(data)
|
|
4059
|
+
? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
|
|
4060
|
+
: 0
|
|
4061
|
+
return { name: author, value: Number(total.toFixed(0)) }
|
|
4062
|
+
})
|
|
4063
|
+
totals.sort((a, b) => b.value - a.value)
|
|
4064
|
+
|
|
4065
|
+
let chartData = []
|
|
4066
|
+
if (topN > 0 && totals.length > topN) {
|
|
4067
|
+
chartData = totals.slice(0, topN)
|
|
4068
|
+
const othersValue = totals
|
|
4069
|
+
.slice(topN)
|
|
4070
|
+
.reduce((sum, item) => sum + item.value, 0)
|
|
4071
|
+
chartData.push({ name: '其他', value: Number(othersValue.toFixed(0)) })
|
|
4072
|
+
} else chartData = totals
|
|
4073
|
+
|
|
4074
|
+
// 4. 自适应颜色生成
|
|
4075
|
+
const generateColors = (count) => {
|
|
4076
|
+
const presets = [
|
|
4077
|
+
'#5470c6',
|
|
4078
|
+
'#91cc75',
|
|
4079
|
+
'#fac858',
|
|
4080
|
+
'#ee6666',
|
|
4081
|
+
'#73c0de',
|
|
4082
|
+
'#3ba272',
|
|
4083
|
+
'#fc8452',
|
|
4084
|
+
'#9a60b4',
|
|
4085
|
+
'#ea7ccc'
|
|
4086
|
+
]
|
|
4087
|
+
if (count <= presets.length) return presets.slice(0, count)
|
|
4088
|
+
|
|
4089
|
+
return chartData.map((_, i) => {
|
|
4090
|
+
if (i < presets.length) return presets[i]
|
|
4091
|
+
// 超过预设后,动态生成 HSL 颜色
|
|
4092
|
+
return `hsl(${(i * 137.5) % 360}, 60%, 65%)` // 使用黄金角度 137.5 确保颜色分布均匀
|
|
4093
|
+
})
|
|
4094
|
+
}
|
|
4095
|
+
|
|
4096
|
+
// FIXME: remove debug log before production
|
|
4097
|
+
console.log('❌', 'chartData', chartData)
|
|
4098
|
+
return drawPieWithTotal({
|
|
4099
|
+
el: 'authorTotalCommitsRankSummary',
|
|
4100
|
+
title: '提交次数排名分布',
|
|
4101
|
+
unit: '次',
|
|
4102
|
+
totalLabel: '总提交',
|
|
4103
|
+
data: chartData,
|
|
4104
|
+
colors: generateColors(totals.length)
|
|
4105
|
+
})
|
|
4106
|
+
}
|
|
4107
|
+
|
|
4108
|
+
function renderAuthorTotalCommitsRankFromDs(ds, topN = 20) {
|
|
4109
|
+
const box = document.getElementById('authorTotalCommitsRank')
|
|
4110
|
+
if (!box) return
|
|
4111
|
+
if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) {
|
|
4112
|
+
box.innerHTML = '<div style="color:#777">暂无提交次数数据</div>'
|
|
4113
|
+
return
|
|
4114
|
+
}
|
|
4115
|
+
|
|
4116
|
+
const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
|
|
4117
|
+
const totals = ds.authors.map((author) => {
|
|
4118
|
+
const data = seriesMap.get(author) || []
|
|
4119
|
+
const total = Array.isArray(data)
|
|
4120
|
+
? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
|
|
4121
|
+
: 0
|
|
4122
|
+
return { author, total }
|
|
4123
|
+
})
|
|
4124
|
+
|
|
4125
|
+
totals.sort(
|
|
4126
|
+
(x, y) =>
|
|
4127
|
+
y.total - x.total || String(x.author).localeCompare(String(y.author))
|
|
4128
|
+
)
|
|
4129
|
+
|
|
4130
|
+
const top = topN > 0 ? totals.slice(0, topN) : totals
|
|
4131
|
+
const count = top.length
|
|
4132
|
+
|
|
4133
|
+
const getColor = (index, totalCount) => {
|
|
4134
|
+
const presetColors = [
|
|
4135
|
+
'#1976d2',
|
|
4136
|
+
'#00a76f',
|
|
4137
|
+
'#fb8c00',
|
|
4138
|
+
'#d32f2f',
|
|
4139
|
+
'#6a1b9a',
|
|
4140
|
+
'#00897b',
|
|
4141
|
+
'#ef5350',
|
|
4142
|
+
'#ffa000',
|
|
4143
|
+
'#5c6bc0',
|
|
4144
|
+
'#43a047'
|
|
4145
|
+
]
|
|
4146
|
+
if (index < presetColors.length && totalCount <= presetColors.length)
|
|
4147
|
+
return presetColors[index]
|
|
4148
|
+
const hue = (index * (360 / totalCount) + 200) % 360
|
|
4149
|
+
return `hsl(${hue}, 65%, 50%)`
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
const safeEscape = (str) =>
|
|
4153
|
+
typeof escapeHtml === 'function' ? escapeHtml(str) : String(str)
|
|
4154
|
+
|
|
4155
|
+
let rangeStr = ''
|
|
4156
|
+
if (Array.isArray(ds.allPeriods) && ds.allPeriods.length) {
|
|
4157
|
+
const first = ds.allPeriods[0]
|
|
4158
|
+
const last = ds.allPeriods[ds.allPeriods.length - 1]
|
|
4159
|
+
const activeType =
|
|
4160
|
+
document.querySelector('#tabsTotalCommits button.active')?.dataset.type ||
|
|
4161
|
+
'daily'
|
|
4162
|
+
|
|
4163
|
+
const periodToDate = (p, type) => {
|
|
4164
|
+
if (type === 'daily') return p
|
|
4165
|
+
if (type === 'weekly' && p.includes('-W')) {
|
|
4166
|
+
const [yy, ww] = p.split('-W')
|
|
4167
|
+
const r = getISOWeekRange(Number(yy), Number(ww))
|
|
4168
|
+
return `${r.start} ~ ${r.end}`
|
|
4169
|
+
}
|
|
4170
|
+
if (type === 'monthly') {
|
|
4171
|
+
const [y, m] = p.split('-')
|
|
4172
|
+
const start = `${y}-${m}-01`
|
|
4173
|
+
const end = new Date(Number(y), Number(m), 0)
|
|
4174
|
+
const endStr = formatDateYMD(end)
|
|
4175
|
+
return `${start} ~ ${endStr}`
|
|
4176
|
+
}
|
|
4177
|
+
if (type === 'yearly') return `${p}-01-01 ~ ${p}-12-31`
|
|
4178
|
+
return p
|
|
4179
|
+
}
|
|
4180
|
+
|
|
4181
|
+
try {
|
|
4182
|
+
const firstRange = periodToDate(first, activeType)
|
|
4183
|
+
const lastRange = periodToDate(last, activeType)
|
|
4184
|
+
if (activeType === 'daily')
|
|
4185
|
+
rangeStr = `统计区间:${firstRange} ~ ${lastRange}`
|
|
4186
|
+
else rangeStr = `统计区间:${firstRange} —— ${lastRange}`
|
|
4187
|
+
rangeStr = `<div style="color:#666;margin-bottom:6px;font-size:13px">${rangeStr}</div>`
|
|
4188
|
+
} catch (e) {
|
|
4189
|
+
rangeStr = ''
|
|
4190
|
+
}
|
|
4191
|
+
}
|
|
4192
|
+
|
|
4193
|
+
const medal = (i) =>
|
|
4194
|
+
i === 0 ? '🥇 ' : i === 1 ? '🥈 ' : i === 2 ? '🥉 ' : `${i + 1}. `
|
|
4195
|
+
|
|
4196
|
+
let totalsList = Array.isArray(ds.totals)
|
|
4197
|
+
? ds.totals.map((t) => ({
|
|
4198
|
+
author: t.author,
|
|
4199
|
+
totalCommits: t.totalCommits,
|
|
4200
|
+
days: t.days,
|
|
4201
|
+
avg: t.avg
|
|
4202
|
+
}))
|
|
4203
|
+
: null
|
|
4204
|
+
if (!totalsList) {
|
|
4205
|
+
totalsList = totals.map((t) => ({
|
|
4206
|
+
author: t.author,
|
|
4207
|
+
totalCommits: Number((t.total || 0).toFixed(0)),
|
|
4208
|
+
days: 0,
|
|
4209
|
+
avg: 0
|
|
4210
|
+
}))
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
const listHtml = count
|
|
4214
|
+
? top
|
|
4215
|
+
.map((t, i) => {
|
|
4216
|
+
const info = totalsList.find((x) => x.author === t.author) || {}
|
|
4217
|
+
const total = Number((info.totalCommits ?? t.total ?? 0).toFixed(0))
|
|
4218
|
+
const days = info.days ?? 0
|
|
4219
|
+
const avg =
|
|
4220
|
+
info.avg ?? (days > 0 ? Number((total / days).toFixed(2)) : 0)
|
|
4221
|
+
const title = `累计提交:${total} 次\n占用天数:${days} 天\n平均每日:${avg} 次`
|
|
4222
|
+
return `
|
|
4223
|
+
<div class="rank-item" title="${title}" style="display: flex; align-items: center; margin-bottom: 8px;">
|
|
4224
|
+
<span class="dot" style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; background:${getColor(i, count)}"></span>
|
|
4225
|
+
<span class="author" style="flex: 1;">
|
|
4226
|
+
${medal(i)}${safeEscape(t.author)}
|
|
4227
|
+
<div style="font-size:12px;color:#666;margin-top:2px">占用天数: ${days} 天 · 平均: ${avg} 次</div>
|
|
4228
|
+
</span>
|
|
4229
|
+
<span class="hours" style="font-weight: bold;">${Number(total).toFixed(0)} 次</span>
|
|
4230
|
+
</div>
|
|
4231
|
+
`
|
|
4232
|
+
})
|
|
4233
|
+
.join('')
|
|
4234
|
+
: '<div style="color:#777">暂无提交次数数据</div>'
|
|
4235
|
+
|
|
4236
|
+
box.innerHTML = `${rangeStr}${listHtml}`
|
|
4237
|
+
}
|
|
4238
|
+
|
|
4239
|
+
// ========= 开发者 午休最晚提交(小时) =========
|
|
4240
|
+
function buildAuthorLunchDataset(
|
|
4241
|
+
commits,
|
|
4242
|
+
type,
|
|
4243
|
+
lunchStart = 12,
|
|
4244
|
+
lunchEnd = 14
|
|
4245
|
+
) {
|
|
4246
|
+
const byAuthor = new Map()
|
|
4247
|
+
const periods = new Set()
|
|
4248
|
+
|
|
4249
|
+
commits.forEach((c) => {
|
|
4250
|
+
const d = new Date(c.date)
|
|
4251
|
+
if (Number.isNaN(d.valueOf())) return
|
|
4252
|
+
const h = d.getHours()
|
|
4253
|
+
const m = d.getMinutes()
|
|
4254
|
+
// 只考虑午休时间段内的提交
|
|
4255
|
+
if (!(h >= lunchStart && h < lunchEnd)) return
|
|
4256
|
+
|
|
4257
|
+
let key
|
|
4258
|
+
if (type === 'daily') key = d.toISOString().slice(0, 10)
|
|
4259
|
+
else if (type === 'weekly')
|
|
4260
|
+
key = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
4261
|
+
else key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
4262
|
+
if (!key) return
|
|
4263
|
+
periods.add(key)
|
|
4264
|
+
|
|
4265
|
+
const author = c.author || 'unknown'
|
|
4266
|
+
if (!byAuthor.has(author)) byAuthor.set(author, {})
|
|
4267
|
+
const obj = byAuthor.get(author)
|
|
4268
|
+
// 以小时小数表示提交时间(例如 12.5 表示 12:30),后者越大表示越靠近午休结束
|
|
4269
|
+
const hourDecimal = h + m / 60
|
|
4270
|
+
obj[key] = Math.max(obj[key] || 0, hourDecimal)
|
|
4271
|
+
})
|
|
4272
|
+
|
|
4273
|
+
const allPeriods = Array.from(periods).sort()
|
|
4274
|
+
const authors = Array.from(byAuthor.keys()).sort()
|
|
4275
|
+
const series = authors.map((a) => ({
|
|
4276
|
+
name: a,
|
|
4277
|
+
type: 'line',
|
|
4278
|
+
smooth: true,
|
|
4279
|
+
data: allPeriods.map((p) => byAuthor.get(a)[p] || 0)
|
|
4280
|
+
}))
|
|
4281
|
+
return { authors, allPeriods, series }
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4284
|
+
function formatHourDecimal(h) {
|
|
4285
|
+
if (h == null || h === 0) return '-'
|
|
4286
|
+
const hh = Math.floor(h)
|
|
4287
|
+
const mm = Math.round((h - hh) * 60)
|
|
4288
|
+
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
function drawAuthorLunchTrends(commits, stats) {
|
|
4292
|
+
const el = document.getElementById('chartAuthorLunch')
|
|
4293
|
+
if (!el) return null
|
|
4294
|
+
const chart = echarts.init(el)
|
|
4295
|
+
chartInstances.push(chart)
|
|
4296
|
+
|
|
4297
|
+
const lunchStart =
|
|
4298
|
+
typeof stats.lunchStart === 'number'
|
|
4299
|
+
? stats.lunchStart
|
|
4300
|
+
: (window.__lunchStart ?? 12)
|
|
4301
|
+
const lunchEnd =
|
|
4302
|
+
typeof stats.lunchEnd === 'number'
|
|
4303
|
+
? stats.lunchEnd
|
|
4304
|
+
: (window.__lunchEnd ?? 14)
|
|
4305
|
+
|
|
4306
|
+
function render(type) {
|
|
4307
|
+
const ds = buildAuthorLunchDataset(commits, type, lunchStart, lunchEnd)
|
|
4308
|
+
ds.rangeMap = {}
|
|
4309
|
+
for (const period of ds.allPeriods) {
|
|
4310
|
+
if (period.includes('-W')) {
|
|
4311
|
+
const [yy, ww] = period.split('-W')
|
|
4312
|
+
ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
|
|
4316
|
+
chart.setOption({
|
|
4317
|
+
tooltip: {
|
|
4318
|
+
trigger: 'axis',
|
|
4319
|
+
formatter(params) {
|
|
4320
|
+
if (!params || !params.length) return ''
|
|
4321
|
+
const label = params[0].axisValue
|
|
4322
|
+
const isWeekly = type === 'weekly'
|
|
4323
|
+
|
|
4324
|
+
let extra = ''
|
|
4325
|
+
if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
|
|
4326
|
+
const { start, end } = ds.rangeMap[label]
|
|
4327
|
+
extra = `<div style="margin-top:4px;color:#999;font-size:12px">周区间:${start} ~ ${end}</div>`
|
|
4328
|
+
}
|
|
4329
|
+
|
|
4330
|
+
const lines = params
|
|
4331
|
+
.filter((i) => i.data > 0)
|
|
4332
|
+
.sort(
|
|
4333
|
+
(a, b) =>
|
|
4334
|
+
(b.data || 0) - (a.data || 0) ||
|
|
4335
|
+
String(a.seriesName).localeCompare(String(b.seriesName))
|
|
4336
|
+
)
|
|
4337
|
+
.map(
|
|
4338
|
+
(item) =>
|
|
4339
|
+
`${item.marker}${item.seriesName}: ${formatHourDecimal(item.data)}`
|
|
4340
|
+
)
|
|
4341
|
+
.join('<br/>')
|
|
4342
|
+
|
|
4343
|
+
return `<div>${label}</div>${extra}${lines}`
|
|
4344
|
+
}
|
|
4345
|
+
},
|
|
4346
|
+
legend: { data: ds.authors },
|
|
4347
|
+
xAxis: { type: 'category', data: ds.allPeriods },
|
|
4348
|
+
yAxis: {
|
|
4349
|
+
type: 'value',
|
|
4350
|
+
name: '时间(小时)',
|
|
4351
|
+
min: lunchStart,
|
|
4352
|
+
max: lunchEnd
|
|
4353
|
+
},
|
|
4354
|
+
series: ds.series
|
|
4355
|
+
})
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
render('daily')
|
|
4359
|
+
|
|
4360
|
+
const tabs = document.querySelectorAll('#tabsLunch button')
|
|
4361
|
+
tabs.forEach((btnEl) => {
|
|
4362
|
+
btnEl.addEventListener('click', () => {
|
|
4363
|
+
tabs.forEach((b) => b.classList.remove('active'))
|
|
4364
|
+
btnEl.classList.add('active')
|
|
4365
|
+
render(btnEl.dataset.type)
|
|
4366
|
+
})
|
|
4367
|
+
})
|
|
4368
|
+
|
|
4369
|
+
renderLunchWeeklyRankSummary(commits, { lunchStart, lunchEnd })
|
|
4370
|
+
renderLunchWeeklyRiskSummary(commits, { lunchStart, lunchEnd })
|
|
4371
|
+
renderLunchMonthlyRankSummary(commits, { lunchStart, lunchEnd })
|
|
4372
|
+
renderLunchMonthlyRiskSummary(commits, { lunchStart, lunchEnd })
|
|
4373
|
+
|
|
4374
|
+
// 点击事件:点击某个数据点(作者+周期)打开侧栏,展示该作者在该周期午休时间段内的提交明细
|
|
4375
|
+
chart.on('click', (p) => {
|
|
4376
|
+
try {
|
|
4377
|
+
if (!p || p.componentType !== 'series') return
|
|
4378
|
+
const label = p.axisValue || p.name
|
|
4379
|
+
const author = p.seriesName
|
|
4380
|
+
if (!label || !author) return
|
|
4381
|
+
|
|
4382
|
+
// 识别当前 tabs 类型(daily|weekly|monthly)
|
|
4383
|
+
const type =
|
|
4384
|
+
document.querySelector('#tabsLunch button.active')?.dataset.type ||
|
|
4385
|
+
'daily'
|
|
4386
|
+
|
|
4387
|
+
// 过滤 commits:作者匹配 + 在午休时间段内 + 在所选周期内
|
|
4388
|
+
const filteredCommits = commits.filter((c) => {
|
|
4389
|
+
const a = c.author || 'unknown'
|
|
4390
|
+
if (a !== author) return false
|
|
4391
|
+
const d = new Date(c.date)
|
|
4392
|
+
if (Number.isNaN(d.valueOf())) return false
|
|
4393
|
+
const h = d.getHours()
|
|
4394
|
+
const m = d.getMinutes()
|
|
4395
|
+
if (!(h >= lunchStart && h < lunchEnd)) return false
|
|
4396
|
+
|
|
4397
|
+
if (type === 'daily') {
|
|
4398
|
+
return d.toISOString().slice(0, 10) === label
|
|
4399
|
+
}
|
|
4400
|
+
if (type === 'weekly') {
|
|
4401
|
+
// label 格式 YYYY-Www
|
|
4402
|
+
if (!label.includes('-W')) return false
|
|
4403
|
+
const [yy, ww] = label.split('-W')
|
|
4404
|
+
const range = getISOWeekRange(Number(yy), Number(ww))
|
|
4405
|
+
const day = d.toISOString().slice(0, 10)
|
|
4406
|
+
return day >= range.start && day <= range.end
|
|
4407
|
+
}
|
|
4408
|
+
// monthly
|
|
4409
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
4410
|
+
return month === label
|
|
4411
|
+
})
|
|
4412
|
+
|
|
4413
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
4414
|
+
|
|
4415
|
+
if (type === 'weekly') {
|
|
4416
|
+
const weeklyItem = {
|
|
4417
|
+
outsideWorkCount: filteredCommits.length,
|
|
4418
|
+
outsideWorkRate: 0
|
|
4419
|
+
}
|
|
4420
|
+
showSideBarForWeek({
|
|
4421
|
+
period: label,
|
|
4422
|
+
weeklyItem,
|
|
4423
|
+
commits: filteredCommits,
|
|
4424
|
+
titleDrawer: `${author} 午休本周提交详情`
|
|
4425
|
+
})
|
|
4426
|
+
} else {
|
|
4427
|
+
showDayDetailSidebar({
|
|
4428
|
+
date: label,
|
|
4429
|
+
count: filteredCommits.length,
|
|
4430
|
+
commits: filteredCommits,
|
|
4431
|
+
titleDrawer: `${author} 午休 ${type} 提交`
|
|
4432
|
+
})
|
|
4433
|
+
}
|
|
4434
|
+
} catch (err) {
|
|
4435
|
+
console.warn('Lunch chart click handler error', err)
|
|
4436
|
+
}
|
|
4437
|
+
})
|
|
4438
|
+
|
|
4439
|
+
return chart
|
|
4440
|
+
}
|
|
4441
|
+
|
|
4442
|
+
// ========== 开发者 累计午休时长(按日/周/月/年累计每日午休占用时长求和) =========
|
|
4443
|
+
function buildAuthorTotalLunchTimeDataset(
|
|
4444
|
+
commits,
|
|
4445
|
+
type,
|
|
4446
|
+
lunchStart = 12,
|
|
4447
|
+
lunchEnd = 14
|
|
4448
|
+
) {
|
|
4449
|
+
// 1. 先统计每位作者每天午休期间的最后一次提交时间(小时小数)
|
|
4450
|
+
const byAuthorDay = new Map()
|
|
4451
|
+
commits.forEach((c) => {
|
|
4452
|
+
const d = new Date(c.date)
|
|
4453
|
+
if (Number.isNaN(d.valueOf())) return
|
|
4454
|
+
const h = d.getHours()
|
|
4455
|
+
const m = d.getMinutes()
|
|
4456
|
+
const hourDecimal = h + m / 60
|
|
4457
|
+
// 仅关注午休时间段内的提交
|
|
4458
|
+
if (!(hourDecimal >= lunchStart && hourDecimal < lunchEnd)) return
|
|
4459
|
+
|
|
4460
|
+
const author = c.author || 'unknown'
|
|
4461
|
+
const day = d.toISOString().slice(0, 10)
|
|
4462
|
+
if (!byAuthorDay.has(author)) byAuthorDay.set(author, new Map())
|
|
4463
|
+
const dayMap = byAuthorDay.get(author)
|
|
4464
|
+
const cur = dayMap.get(day) || 0
|
|
4465
|
+
dayMap.set(day, Math.max(cur, hourDecimal))
|
|
4466
|
+
})
|
|
4467
|
+
|
|
4468
|
+
// 2. 按周期(type)聚合,统计每一周期内的累计午休占用时长
|
|
4469
|
+
const byAuthorPeriod = new Map()
|
|
4470
|
+
const periods = new Set()
|
|
4471
|
+
|
|
4472
|
+
byAuthorDay.forEach((dayMap, author) => {
|
|
4473
|
+
for (const [day, lastHour] of dayMap.entries()) {
|
|
4474
|
+
let period
|
|
4475
|
+
if (type === 'daily') period = day
|
|
4476
|
+
else if (type === 'weekly') period = getIsoWeekKey(day)
|
|
4477
|
+
else if (type === 'monthly') period = day.slice(0, 7)
|
|
4478
|
+
else if (type === 'yearly') period = day.slice(0, 4)
|
|
4479
|
+
else period = day
|
|
4480
|
+
if (!period) continue
|
|
4481
|
+
periods.add(period)
|
|
4482
|
+
if (!byAuthorPeriod.has(author)) byAuthorPeriod.set(author, {})
|
|
4483
|
+
const obj = byAuthorPeriod.get(author)
|
|
4484
|
+
// 当天占用午休时长 = max(0, min(lastHour, lunchEnd) - lunchStart)
|
|
4485
|
+
const capped = Math.min(lastHour, lunchEnd)
|
|
4486
|
+
const duration = Math.max(0, capped - lunchStart)
|
|
4487
|
+
obj[period] = (obj[period] || 0) + duration
|
|
4488
|
+
}
|
|
4489
|
+
})
|
|
4490
|
+
|
|
4491
|
+
const allPeriods = Array.from(periods).sort()
|
|
4492
|
+
const authors = Array.from(byAuthorPeriod.keys()).sort()
|
|
4493
|
+
const series = authors.map((a) => ({
|
|
4494
|
+
name: a,
|
|
4495
|
+
type: 'line',
|
|
4496
|
+
smooth: true,
|
|
4497
|
+
data: allPeriods.map((p) =>
|
|
4498
|
+
Number((byAuthorPeriod.get(a)[p] || 0).toFixed(2))
|
|
4499
|
+
)
|
|
4500
|
+
}))
|
|
4501
|
+
|
|
4502
|
+
// 3. 计算每个作者的总时长/占用天数/日均占用,方便列表与 tooltip 使用
|
|
4503
|
+
const totals = authors.map((a) => {
|
|
4504
|
+
const periodObj = byAuthorPeriod.get(a) || {}
|
|
4505
|
+
const totalHours = Object.values(periodObj).reduce(
|
|
4506
|
+
(s, v) => s + (Number(v) || 0),
|
|
4507
|
+
0
|
|
4508
|
+
)
|
|
4509
|
+
const days = byAuthorDay.get(a) ? byAuthorDay.get(a).size : 0
|
|
4510
|
+
const avg = days > 0 ? Number((totalHours / days).toFixed(2)) : 0
|
|
4511
|
+
return { author: a, totalHours: Number(totalHours.toFixed(2)), days, avg }
|
|
4512
|
+
})
|
|
4513
|
+
|
|
4514
|
+
return { authors, allPeriods, series, totals }
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
function drawAuthorTotalLunchTimeTrends(commits, stats) {
|
|
4518
|
+
const el = document.getElementById('chartAuthorTotalLunchTime')
|
|
4519
|
+
if (!el) return null
|
|
4520
|
+
const chart = echarts.init(el)
|
|
4521
|
+
chartInstances.push(chart)
|
|
4522
|
+
|
|
4523
|
+
const lunchStart =
|
|
4524
|
+
typeof stats.lunchStart === 'number'
|
|
4525
|
+
? stats.lunchStart
|
|
4526
|
+
: (window.__lunchStart ?? 12)
|
|
4527
|
+
const lunchEnd =
|
|
4528
|
+
typeof stats.lunchEnd === 'number'
|
|
4529
|
+
? stats.lunchEnd
|
|
4530
|
+
: (window.__lunchEnd ?? 14)
|
|
4531
|
+
|
|
4532
|
+
function render(type) {
|
|
4533
|
+
const ds = buildAuthorTotalLunchTimeDataset(
|
|
4534
|
+
commits,
|
|
4535
|
+
type,
|
|
4536
|
+
lunchStart,
|
|
4537
|
+
lunchEnd
|
|
4538
|
+
)
|
|
4539
|
+
ds.rangeMap = {}
|
|
4540
|
+
for (const period of ds.allPeriods) {
|
|
4541
|
+
if (period.includes('-W')) {
|
|
4542
|
+
const [yy, ww] = period.split('-W')
|
|
4543
|
+
ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
|
|
4547
|
+
chart.setOption({
|
|
4548
|
+
tooltip: {
|
|
4549
|
+
trigger: 'axis',
|
|
4550
|
+
formatter(params) {
|
|
4551
|
+
if (!params || !params.length) return ''
|
|
4552
|
+
const label = params[0].axisValue
|
|
4553
|
+
const isWeekly = type === 'weekly'
|
|
4554
|
+
|
|
4555
|
+
let extra = ''
|
|
4556
|
+
if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
|
|
4557
|
+
const { start, end } = ds.rangeMap[label]
|
|
4558
|
+
extra = `<div style="margin-top:4px;color:#999;font-size:12px">周区间:${start} ~ ${end}</div>`
|
|
4559
|
+
}
|
|
4560
|
+
|
|
4561
|
+
const lines = params
|
|
4562
|
+
.filter((i) => i.data > 0)
|
|
4563
|
+
.sort(
|
|
4564
|
+
(a, b) =>
|
|
4565
|
+
(b.data || 0) - (a.data || 0) ||
|
|
4566
|
+
String(a.seriesName).localeCompare(String(b.seriesName))
|
|
4567
|
+
)
|
|
4568
|
+
.map(
|
|
4569
|
+
(item) =>
|
|
4570
|
+
`${item.marker}${item.seriesName}: ${Number(item.data).toFixed(2)} 小时`
|
|
4571
|
+
)
|
|
4572
|
+
.join('<br/>')
|
|
4573
|
+
|
|
4574
|
+
return `<div>${label}</div>${extra}${lines}`
|
|
4575
|
+
}
|
|
4576
|
+
},
|
|
4577
|
+
legend: { data: ds.authors },
|
|
4578
|
+
xAxis: { type: 'category', data: ds.allPeriods },
|
|
4579
|
+
yAxis: { type: 'value', name: '累计午休工作时长 (小时)', min: 0 },
|
|
4580
|
+
series: ds.series
|
|
4581
|
+
})
|
|
4582
|
+
|
|
4583
|
+
// 更新排名与饼图
|
|
4584
|
+
try {
|
|
4585
|
+
renderAuthorTotalLunchTimeRankFromDs(ds, 0)
|
|
4586
|
+
renderAuthorTotalLunchTimeRank(ds, 0)
|
|
4587
|
+
} catch (e) {
|
|
4588
|
+
console.warn('更新累计午休排名失败', e)
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
|
|
4592
|
+
render('daily')
|
|
4593
|
+
|
|
4594
|
+
const tabs = document.querySelectorAll('#tabsTotalLunchTime button')
|
|
4595
|
+
tabs.forEach((btnEl) => {
|
|
4596
|
+
btnEl.addEventListener('click', () => {
|
|
4597
|
+
tabs.forEach((b) => b.classList.remove('active'))
|
|
4598
|
+
btnEl.classList.add('active')
|
|
4599
|
+
render(btnEl.dataset.type)
|
|
4600
|
+
})
|
|
4601
|
+
})
|
|
4602
|
+
|
|
4603
|
+
chart.on('click', (p) => {
|
|
4604
|
+
try {
|
|
4605
|
+
if (!p || p.componentType !== 'series') return
|
|
4606
|
+
const label = p.axisValue || p.name
|
|
4607
|
+
const author = p.seriesName
|
|
4608
|
+
if (!label || !author) return
|
|
4609
|
+
const type =
|
|
4610
|
+
document.querySelector('#tabsTotalLunchTime button.active')?.dataset
|
|
4611
|
+
.type || 'daily'
|
|
4612
|
+
|
|
4613
|
+
const filteredCommits = commits.filter((c) => {
|
|
4614
|
+
const a = c.author || 'unknown'
|
|
4615
|
+
if (a !== author) return false
|
|
4616
|
+
const d = new Date(c.date)
|
|
4617
|
+
if (Number.isNaN(d.valueOf())) return false
|
|
4618
|
+
const h = d.getHours()
|
|
4619
|
+
const m = d.getMinutes()
|
|
4620
|
+
const hourDecimal = h + m / 60
|
|
4621
|
+
if (!(hourDecimal >= lunchStart && hourDecimal < lunchEnd)) return false
|
|
4622
|
+
|
|
4623
|
+
if (type === 'daily') return d.toISOString().slice(0, 10) === label
|
|
4624
|
+
if (type === 'weekly') {
|
|
4625
|
+
if (!label.includes('-W')) return false
|
|
4626
|
+
const [yy, ww] = label.split('-W')
|
|
4627
|
+
const range = getISOWeekRange(Number(yy), Number(ww))
|
|
4628
|
+
const day = d.toISOString().slice(0, 10)
|
|
4629
|
+
return day >= range.start && day <= range.end
|
|
4630
|
+
}
|
|
4631
|
+
if (type === 'monthly') {
|
|
4632
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
4633
|
+
return month === label
|
|
4634
|
+
}
|
|
4635
|
+
const year = String(d.getFullYear())
|
|
4636
|
+
return year === label
|
|
4637
|
+
})
|
|
4638
|
+
|
|
4639
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
4640
|
+
|
|
4641
|
+
if (type === 'weekly') {
|
|
4642
|
+
const weeklyItem = {
|
|
4643
|
+
outsideWorkCount: filteredCommits.length,
|
|
4644
|
+
outsideWorkRate: 0
|
|
4645
|
+
}
|
|
4646
|
+
showSideBarForWeek({
|
|
4647
|
+
period: label,
|
|
4648
|
+
weeklyItem,
|
|
4649
|
+
commits: filteredCommits,
|
|
4650
|
+
titleDrawer: `${author} 午休本周详情`
|
|
4651
|
+
})
|
|
4652
|
+
} else {
|
|
4653
|
+
showDayDetailSidebar({
|
|
4654
|
+
date: label,
|
|
4655
|
+
count: filteredCommits.length,
|
|
4656
|
+
commits: filteredCommits,
|
|
4657
|
+
titleDrawer: `${author} 午休 ${type} 详情`
|
|
4658
|
+
})
|
|
4659
|
+
}
|
|
4660
|
+
} catch (err) {
|
|
4661
|
+
console.warn('Total lunch chart click handler error', err)
|
|
4662
|
+
}
|
|
4663
|
+
})
|
|
4664
|
+
|
|
4665
|
+
return chart
|
|
4666
|
+
}
|
|
4667
|
+
|
|
4668
|
+
function renderLunchWeeklyRankSummary(
|
|
4669
|
+
commits,
|
|
4670
|
+
{ lunchStart = 12, lunchEnd = 14 } = {}
|
|
4671
|
+
) {
|
|
4672
|
+
const box = document.getElementById('lunchWeeklyRankSummary')
|
|
4673
|
+
if (!box) return
|
|
4674
|
+
|
|
4675
|
+
const now = new Date()
|
|
4676
|
+
const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
|
|
4677
|
+
|
|
4678
|
+
const weekDays = new Map() // week -> Map(author -> Set(dates))
|
|
4679
|
+
commits.forEach((c) => {
|
|
4680
|
+
const d = new Date(c.date)
|
|
4681
|
+
if (Number.isNaN(d.valueOf())) return
|
|
4682
|
+
const h = d.getHours()
|
|
4683
|
+
const m = d.getMinutes()
|
|
4684
|
+
if (!(h >= lunchStart && h < lunchEnd)) return
|
|
4685
|
+
const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
4686
|
+
if (wKey !== curKey) return
|
|
4687
|
+
const author = c.author || 'unknown'
|
|
4688
|
+
if (!weekDays.has(author)) weekDays.set(author, new Set())
|
|
4689
|
+
weekDays.get(author).add(d.toISOString().slice(0, 10))
|
|
4690
|
+
})
|
|
4691
|
+
|
|
4692
|
+
const weeklyRanks = []
|
|
4693
|
+
weekDays.forEach((set, author) => {
|
|
4694
|
+
weeklyRanks.push({ author, days: set.size })
|
|
4695
|
+
})
|
|
4696
|
+
weeklyRanks.sort(
|
|
4697
|
+
(a, b) =>
|
|
4698
|
+
b.days - a.days || String(a.author).localeCompare(String(b.author))
|
|
4699
|
+
)
|
|
4700
|
+
|
|
4701
|
+
const lines = []
|
|
4702
|
+
lines.push('【本周午休清醒者排行榜】')
|
|
4703
|
+
if (weeklyRanks.length === 0) {
|
|
4704
|
+
lines.push('本周无人午休提交,暂无清醒者排行榜。')
|
|
4705
|
+
} else {
|
|
4706
|
+
weeklyRanks.forEach((r, idx) => {
|
|
4707
|
+
const rank = idx + 1
|
|
4708
|
+
const medal =
|
|
4709
|
+
rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
|
|
4710
|
+
const title = rank === 1 ? '(状元・昼魔侠)' : ''
|
|
4711
|
+
lines.push(`${rank}. ${medal}${r.author} — ${r.days} 天${title}`)
|
|
4712
|
+
})
|
|
4713
|
+
}
|
|
4714
|
+
|
|
4715
|
+
box.innerHTML = `
|
|
4716
|
+
<div class="risk-summary">
|
|
4717
|
+
<div class="risk-title">【本周午休清醒者排行榜】</div>
|
|
4718
|
+
<ul>
|
|
4719
|
+
${lines
|
|
4720
|
+
.slice(1)
|
|
4721
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
4722
|
+
.join('')}
|
|
4723
|
+
</ul>
|
|
4724
|
+
</div>
|
|
4725
|
+
`
|
|
4726
|
+
}
|
|
4727
|
+
|
|
4728
|
+
function renderLunchWeeklyRiskSummary(
|
|
4729
|
+
commits,
|
|
4730
|
+
{ lunchStart = 12, lunchEnd = 14 } = {}
|
|
4731
|
+
) {
|
|
4732
|
+
const box = document.getElementById('lunchWeeklyRiskSummary')
|
|
4733
|
+
if (!box) return
|
|
4734
|
+
|
|
4735
|
+
const now = new Date()
|
|
4736
|
+
const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
|
|
4737
|
+
const prev = new Date(now)
|
|
4738
|
+
prev.setDate(prev.getDate() - 7)
|
|
4739
|
+
const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
|
|
4740
|
+
|
|
4741
|
+
const weekMax = new Map() // week -> Map(author -> {val, date, time})
|
|
4742
|
+
commits.forEach((c) => {
|
|
4743
|
+
const d = new Date(c.date)
|
|
4744
|
+
if (Number.isNaN(d.valueOf())) return
|
|
4745
|
+
const h = d.getHours()
|
|
4746
|
+
const m = d.getMinutes()
|
|
4747
|
+
if (!(h >= lunchStart && h < lunchEnd)) return
|
|
4748
|
+
const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
4749
|
+
if (!wKey) return
|
|
4750
|
+
|
|
4751
|
+
if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
|
|
4752
|
+
const mMap = weekMax.get(wKey)
|
|
4753
|
+
const author = c.author || 'unknown'
|
|
4754
|
+
const val = h + m / 60
|
|
4755
|
+
const cur = mMap.get(author)
|
|
4756
|
+
if (!cur || val > cur.val)
|
|
4757
|
+
mMap.set(author, {
|
|
4758
|
+
val,
|
|
4759
|
+
date: d.toISOString().slice(0, 10),
|
|
4760
|
+
time: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
|
4761
|
+
})
|
|
4762
|
+
})
|
|
4763
|
+
|
|
4764
|
+
const curMap = weekMax.get(curKey) || new Map()
|
|
4765
|
+
const prevMap = weekMax.get(prevKey) || new Map()
|
|
4766
|
+
|
|
4767
|
+
let topAuthor = null
|
|
4768
|
+
let top = { val: -1, date: null, time: null }
|
|
4769
|
+
curMap.forEach((v, k) => {
|
|
4770
|
+
if (v.val > top.val) {
|
|
4771
|
+
top = v
|
|
4772
|
+
topAuthor = k
|
|
4773
|
+
}
|
|
4774
|
+
})
|
|
4775
|
+
|
|
4776
|
+
let prevMax = -1
|
|
4777
|
+
prevMap.forEach((v) => {
|
|
4778
|
+
if (v.val > prevMax) prevMax = v.val
|
|
4779
|
+
})
|
|
4780
|
+
|
|
4781
|
+
const lines = []
|
|
4782
|
+
lines.push('【本周午休最晚提交风险】')
|
|
4783
|
+
|
|
4784
|
+
if (top.val < 0) {
|
|
4785
|
+
lines.push('本周午休期间暂无提交记录。')
|
|
4786
|
+
} else {
|
|
4787
|
+
let trend = '暂无上周对比'
|
|
4788
|
+
if (prevMax >= 0) {
|
|
4789
|
+
if (top.val > prevMax) trend = '较上周更晚'
|
|
4790
|
+
else if (top.val < prevMax) trend = '较上周提前'
|
|
4791
|
+
else trend = '与上周持平'
|
|
4792
|
+
}
|
|
4793
|
+
lines.push(
|
|
4794
|
+
`${topAuthor} 本周午休最晚提交:${top.time}(${top.date}),${trend}。)`
|
|
4795
|
+
)
|
|
4796
|
+
if (top.val >= lunchEnd - 0.5)
|
|
4797
|
+
lines.push('存在午间延迟提交风险,请关注短时间内频繁占用午休。')
|
|
4798
|
+
}
|
|
4799
|
+
|
|
4800
|
+
box.innerHTML = `
|
|
4801
|
+
<div class="risk-summary">
|
|
4802
|
+
<div class="risk-title">【本周午休最晚提交风险】</div>
|
|
4803
|
+
<ul>
|
|
4804
|
+
${lines
|
|
4805
|
+
.slice(1)
|
|
4806
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
4807
|
+
.join('')}
|
|
4808
|
+
</ul>
|
|
4809
|
+
</div>
|
|
4810
|
+
`
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
function renderLunchMonthlyRankSummary(
|
|
4814
|
+
commits,
|
|
4815
|
+
{ lunchStart = 12, lunchEnd = 14 } = {}
|
|
4816
|
+
) {
|
|
4817
|
+
const box = document.getElementById('lunchMonthlyRankSummary')
|
|
4818
|
+
if (!box) return
|
|
4819
|
+
|
|
4820
|
+
const now = new Date()
|
|
4821
|
+
const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
4822
|
+
|
|
4823
|
+
const monthDays = new Map() // author -> Set(dates)
|
|
4824
|
+
commits.forEach((c) => {
|
|
4825
|
+
const d = new Date(c.date)
|
|
4826
|
+
if (Number.isNaN(d.valueOf())) return
|
|
4827
|
+
const h = d.getHours()
|
|
4828
|
+
const m = d.getMinutes()
|
|
4829
|
+
if (!(h >= lunchStart && h < lunchEnd)) return
|
|
4830
|
+
const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
4831
|
+
if (mKey !== curKey) return
|
|
4832
|
+
const author = c.author || 'unknown'
|
|
4833
|
+
if (!monthDays.has(author)) monthDays.set(author, new Set())
|
|
4834
|
+
monthDays.get(author).add(d.toISOString().slice(0, 10))
|
|
4835
|
+
})
|
|
4836
|
+
|
|
4837
|
+
const monthlyRanks = []
|
|
4838
|
+
monthDays.forEach((set, author) => {
|
|
4839
|
+
monthlyRanks.push({ author, days: set.size })
|
|
4840
|
+
})
|
|
4841
|
+
monthlyRanks.sort(
|
|
4842
|
+
(a, b) =>
|
|
4843
|
+
b.days - a.days || String(a.author).localeCompare(String(b.author))
|
|
4844
|
+
)
|
|
4845
|
+
|
|
4846
|
+
const lines = []
|
|
4847
|
+
lines.push('【本月午休清醒者排行榜】')
|
|
4848
|
+
if (monthlyRanks.length === 0) {
|
|
4849
|
+
lines.push('本月无人午休提交,暂无清醒者排行榜。')
|
|
4850
|
+
} else {
|
|
4851
|
+
monthlyRanks.forEach((r, idx) => {
|
|
4852
|
+
const rank = idx + 1
|
|
4853
|
+
const medal =
|
|
4854
|
+
rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
|
|
4855
|
+
const title = rank === 1 ? '(状元・昼魔侠)' : ''
|
|
4856
|
+
lines.push(`${rank}. ${medal}${r.author} — ${r.days} 天${title}`)
|
|
4857
|
+
})
|
|
4858
|
+
}
|
|
4859
|
+
|
|
4860
|
+
box.innerHTML = `
|
|
4861
|
+
<div class="risk-summary">
|
|
4862
|
+
<div class="risk-title">【本月午休清醒者排行榜】</div>
|
|
4863
|
+
<ul>
|
|
4864
|
+
${lines
|
|
4865
|
+
.slice(1)
|
|
4866
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
4867
|
+
.join('')}
|
|
4868
|
+
</ul>
|
|
4869
|
+
</div>
|
|
4870
|
+
`
|
|
4871
|
+
}
|
|
4872
|
+
|
|
4873
|
+
function renderLunchMonthlyRiskSummary(
|
|
4874
|
+
commits,
|
|
4875
|
+
{ lunchStart = 12, lunchEnd = 14 } = {}
|
|
4876
|
+
) {
|
|
4877
|
+
const box = document.getElementById('lunchMonthlyRiskSummary')
|
|
4878
|
+
if (!box) return
|
|
4879
|
+
|
|
4880
|
+
const now = new Date()
|
|
4881
|
+
const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
4882
|
+
const prev = new Date(now)
|
|
4883
|
+
prev.setMonth(prev.getMonth() - 1)
|
|
4884
|
+
const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
|
|
4885
|
+
|
|
4886
|
+
const monthMax = new Map()
|
|
4887
|
+
commits.forEach((c) => {
|
|
4888
|
+
const d = new Date(c.date)
|
|
4889
|
+
if (Number.isNaN(d.valueOf())) return
|
|
4890
|
+
const h = d.getHours()
|
|
4891
|
+
const m = d.getMinutes()
|
|
4892
|
+
if (!(h >= lunchStart && h < lunchEnd)) return
|
|
4893
|
+
const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
4894
|
+
if (!monthMax.has(mKey)) monthMax.set(mKey, new Map())
|
|
4895
|
+
const mm = monthMax.get(mKey)
|
|
4896
|
+
const author = c.author || 'unknown'
|
|
4897
|
+
const val = h + m / 60
|
|
4898
|
+
const cur = mm.get(author)
|
|
4899
|
+
if (!cur || val > cur.val)
|
|
4900
|
+
mm.set(author, {
|
|
4901
|
+
val,
|
|
4902
|
+
date: d.toISOString().slice(0, 10),
|
|
4903
|
+
time: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
|
4904
|
+
})
|
|
4905
|
+
})
|
|
4906
|
+
|
|
4907
|
+
const curMap = monthMax.get(curKey) || new Map()
|
|
4908
|
+
const prevMap = monthMax.get(prevKey) || new Map()
|
|
4909
|
+
|
|
4910
|
+
let topAuthor = null
|
|
4911
|
+
let top = { val: -1, date: null }
|
|
4912
|
+
curMap.forEach((v, k) => {
|
|
4913
|
+
if (v.val > top.val) {
|
|
4914
|
+
top = v
|
|
4915
|
+
topAuthor = k
|
|
4916
|
+
}
|
|
4917
|
+
})
|
|
4918
|
+
|
|
4919
|
+
let prevMax = -1
|
|
4920
|
+
prevMap.forEach((v) => {
|
|
4921
|
+
if (v.val > prevMax) prevMax = v.val
|
|
4922
|
+
})
|
|
4923
|
+
|
|
4924
|
+
const lines = []
|
|
4925
|
+
lines.push('【本月午休最晚提交风险】')
|
|
4926
|
+
|
|
4927
|
+
if (top.val < 0) {
|
|
4928
|
+
lines.push('本月午休期间暂无提交记录。')
|
|
4929
|
+
} else {
|
|
4930
|
+
let trend = '暂无上月对比'
|
|
4931
|
+
if (prevMax >= 0) {
|
|
4932
|
+
if (top.val > prevMax) trend = '较上月更晚'
|
|
4933
|
+
else if (top.val < prevMax) trend = '较上月提前'
|
|
4934
|
+
else trend = '与上月持平'
|
|
4935
|
+
}
|
|
4936
|
+
lines.push(
|
|
4937
|
+
`${topAuthor} 本月午休最晚提交:${top.time}(${top.date}),${trend}。)`
|
|
4938
|
+
)
|
|
4939
|
+
if (top.val >= lunchEnd - 0.5)
|
|
4940
|
+
lines.push('存在午间延迟提交风险,请关注短时间内频繁占用午休。')
|
|
4941
|
+
}
|
|
4942
|
+
|
|
4943
|
+
box.innerHTML = `
|
|
4944
|
+
<div class="risk-summary">
|
|
4945
|
+
<div class="risk-title">【本月午休最晚提交风险】</div>
|
|
4946
|
+
<ul>
|
|
4947
|
+
${lines
|
|
4948
|
+
.slice(1)
|
|
4949
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
4950
|
+
.join('')}
|
|
4951
|
+
</ul>
|
|
4952
|
+
</div>
|
|
4953
|
+
`
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
// ====== 前端计算 overtime 数据的函数 ======
|
|
4957
|
+
|
|
4958
|
+
/**
|
|
4959
|
+
* 根据 commits 和配置计算小时加班统计
|
|
4960
|
+
*/
|
|
4961
|
+
function computeHourlyOvertime(commits, config) {
|
|
4962
|
+
const startHour = config.startHour ?? 9
|
|
4963
|
+
const endHour = config.endHour ?? 18
|
|
4964
|
+
const lunchStart = config.lunchStart ?? 12
|
|
4965
|
+
const lunchEnd = config.lunchEnd ?? 14
|
|
4966
|
+
|
|
4967
|
+
const hourlyCommits = Array(24).fill(0)
|
|
4968
|
+
const hourlyOvertimeCommits = Array(24).fill(0)
|
|
4969
|
+
const hourlyOvertimePercent = Array(24).fill(0)
|
|
4970
|
+
|
|
4971
|
+
let latestCommitHour = -1
|
|
4972
|
+
let latestCommit = null
|
|
4973
|
+
let total = 0
|
|
4974
|
+
let outsideWorkCount = 0
|
|
4975
|
+
let latestOutsideCommit = null
|
|
4976
|
+
let latestOutsideCommitHour = -1
|
|
4977
|
+
|
|
4978
|
+
commits.forEach((c) => {
|
|
4979
|
+
const d = new Date(c.date)
|
|
4980
|
+
if (isNaN(d.getTime())) return
|
|
4981
|
+
|
|
4982
|
+
const h = d.getHours()
|
|
4983
|
+
const m = d.getMinutes()
|
|
4984
|
+
|
|
4985
|
+
hourlyCommits[h]++
|
|
4986
|
+
total++
|
|
4987
|
+
|
|
4988
|
+
// 更新最后一条提交
|
|
4989
|
+
if (!latestCommit || new Date(c.date) > new Date(latestCommit.date)) {
|
|
4990
|
+
latestCommit = c
|
|
4991
|
+
latestCommitHour = h
|
|
4992
|
+
}
|
|
4993
|
+
|
|
4994
|
+
// 判断是否加班:与后端保持一致——非工作时间即为加班
|
|
4995
|
+
// 工作时间定义:startHour <= hour < endHour,且排除午休区间
|
|
4996
|
+
const inWorkHours =
|
|
4997
|
+
h >= startHour && h < endHour && !(h >= lunchStart && h < lunchEnd)
|
|
4998
|
+
const isOvertime = !inWorkHours
|
|
4999
|
+
|
|
5000
|
+
if (isOvertime) {
|
|
5001
|
+
hourlyOvertimeCommits[h]++
|
|
5002
|
+
outsideWorkCount++
|
|
5003
|
+
// 跟踪最晚的加班提交(按严重度:小时越大越晚)
|
|
5004
|
+
if (!latestOutsideCommit) {
|
|
5005
|
+
latestOutsideCommit = c
|
|
5006
|
+
latestOutsideCommitHour = h
|
|
5007
|
+
} else {
|
|
5008
|
+
const curSev = h >= endHour ? h - endHour : 24 - endHour + h
|
|
5009
|
+
const prevSev =
|
|
5010
|
+
latestOutsideCommitHour >= endHour
|
|
5011
|
+
? latestOutsideCommitHour - endHour
|
|
5012
|
+
: 24 - endHour + latestOutsideCommitHour
|
|
5013
|
+
if (
|
|
5014
|
+
curSev > prevSev ||
|
|
5015
|
+
(curSev === prevSev &&
|
|
5016
|
+
new Date(c.date) > new Date(latestOutsideCommit.date))
|
|
5017
|
+
) {
|
|
5018
|
+
latestOutsideCommit = c
|
|
5019
|
+
latestOutsideCommitHour = h
|
|
5020
|
+
}
|
|
5021
|
+
}
|
|
5022
|
+
}
|
|
5023
|
+
})
|
|
5024
|
+
|
|
5025
|
+
// 计算百分比
|
|
5026
|
+
for (let i = 0; i < 24; i++) {
|
|
5027
|
+
hourlyOvertimePercent[i] = total > 0 ? hourlyOvertimeCommits[i] / total : 0
|
|
5028
|
+
}
|
|
5029
|
+
|
|
5030
|
+
return {
|
|
5031
|
+
startHour,
|
|
5032
|
+
endHour,
|
|
5033
|
+
lunchStart,
|
|
5034
|
+
lunchEnd,
|
|
5035
|
+
hourlyOvertimeCommits,
|
|
5036
|
+
hourlyOvertimePercent,
|
|
5037
|
+
latestCommitHour,
|
|
5038
|
+
latestCommit,
|
|
5039
|
+
latestOutsideCommit,
|
|
5040
|
+
latestOutsideCommitHour,
|
|
5041
|
+
total,
|
|
5042
|
+
outsideWorkCount,
|
|
5043
|
+
outsideWorkRate: total > 0 ? outsideWorkCount / total : 0
|
|
5044
|
+
}
|
|
5045
|
+
}
|
|
5046
|
+
|
|
5047
|
+
/**
|
|
5048
|
+
* 根据 commits 计算每周加班统计
|
|
5049
|
+
*/
|
|
5050
|
+
function computeWeeklyOvertime(
|
|
5051
|
+
commits,
|
|
5052
|
+
startHour,
|
|
5053
|
+
endHour,
|
|
5054
|
+
cutoff,
|
|
5055
|
+
lunchStart,
|
|
5056
|
+
lunchEnd
|
|
5057
|
+
) {
|
|
5058
|
+
const weekMap = new Map()
|
|
5059
|
+
|
|
5060
|
+
// 第一步:按周分组统计加班提交
|
|
5061
|
+
commits.forEach((c) => {
|
|
5062
|
+
const d = new Date(c.date)
|
|
5063
|
+
const h = d.getHours()
|
|
5064
|
+
|
|
5065
|
+
// 判断是否在工作时间(与后端保持一致)
|
|
5066
|
+
// 工作时间是 startHour <= hour < endHour,但排除午休 lunchStart <= hour < lunchEnd
|
|
5067
|
+
const inWorkHours =
|
|
5068
|
+
h >= startHour && h < endHour && !(h >= lunchStart && h < lunchEnd)
|
|
5069
|
+
const isOvertime = !inWorkHours
|
|
5070
|
+
if (!isOvertime) return
|
|
5071
|
+
|
|
5072
|
+
const weekKey = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
5073
|
+
if (!weekKey) return
|
|
5074
|
+
|
|
5075
|
+
if (!weekMap.has(weekKey)) {
|
|
5076
|
+
weekMap.set(weekKey, {
|
|
5077
|
+
period: weekKey,
|
|
5078
|
+
outsideWorkCount: 0,
|
|
5079
|
+
outsideWorkRate: 0,
|
|
5080
|
+
range: { start: '', end: '' }
|
|
5081
|
+
})
|
|
5082
|
+
}
|
|
5083
|
+
|
|
5084
|
+
weekMap.get(weekKey).outsideWorkCount++
|
|
5085
|
+
})
|
|
5086
|
+
|
|
5087
|
+
// 第二步:计算每周的总 commits 数以便计算比例
|
|
5088
|
+
const totalByWeek = new Map()
|
|
5089
|
+
commits.forEach((c) => {
|
|
5090
|
+
const d = new Date(c.date)
|
|
5091
|
+
const weekKey = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
5092
|
+
if (weekKey) {
|
|
5093
|
+
totalByWeek.set(weekKey, (totalByWeek.get(weekKey) || 0) + 1)
|
|
5094
|
+
}
|
|
5095
|
+
})
|
|
5096
|
+
|
|
5097
|
+
// 第三步:计算比例并填充周范围
|
|
5098
|
+
const weekly = Array.from(weekMap.values())
|
|
5099
|
+
weekly.forEach((w) => {
|
|
5100
|
+
const total = totalByWeek.get(w.period) || 1
|
|
5101
|
+
w.outsideWorkRate = w.outsideWorkCount / total
|
|
5102
|
+
|
|
5103
|
+
// 填充周的日期范围
|
|
5104
|
+
const [yy, ww] = w.period.split('-W')
|
|
5105
|
+
w.range = getISOWeekRange(Number(yy), Number(ww))
|
|
5106
|
+
})
|
|
5107
|
+
|
|
5108
|
+
return weekly.sort((a, b) => a.period.localeCompare(b.period))
|
|
5109
|
+
}
|
|
5110
|
+
|
|
5111
|
+
/**
|
|
5112
|
+
* 根据 commits 计算每月加班统计
|
|
5113
|
+
*/
|
|
5114
|
+
function computeMonthlyOvertime(
|
|
5115
|
+
commits,
|
|
5116
|
+
startHour,
|
|
5117
|
+
endHour,
|
|
5118
|
+
cutoff,
|
|
5119
|
+
lunchStart,
|
|
5120
|
+
lunchEnd
|
|
5121
|
+
) {
|
|
5122
|
+
const monthMap = new Map()
|
|
5123
|
+
|
|
5124
|
+
commits.forEach((c) => {
|
|
5125
|
+
const d = new Date(c.date)
|
|
5126
|
+
const h = d.getHours()
|
|
5127
|
+
|
|
5128
|
+
// 判断是否在工作时间(与后端保持一致)
|
|
5129
|
+
const inWorkHours =
|
|
5130
|
+
h >= startHour && h < endHour && !(h >= lunchStart && h < lunchEnd)
|
|
5131
|
+
const isOvertime = !inWorkHours
|
|
5132
|
+
if (!isOvertime) return
|
|
5133
|
+
|
|
5134
|
+
const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
5135
|
+
|
|
5136
|
+
if (!monthMap.has(monthKey)) {
|
|
5137
|
+
monthMap.set(monthKey, {
|
|
5138
|
+
period: monthKey,
|
|
5139
|
+
outsideWorkCount: 0,
|
|
5140
|
+
outsideWorkRate: 0
|
|
5141
|
+
})
|
|
5142
|
+
}
|
|
5143
|
+
|
|
5144
|
+
monthMap.get(monthKey).outsideWorkCount++
|
|
5145
|
+
})
|
|
5146
|
+
|
|
5147
|
+
// 计算比例
|
|
5148
|
+
const totalByMonth = new Map()
|
|
5149
|
+
commits.forEach((c) => {
|
|
5150
|
+
const d = new Date(c.date)
|
|
5151
|
+
const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
5152
|
+
totalByMonth.set(monthKey, (totalByMonth.get(monthKey) || 0) + 1)
|
|
5153
|
+
})
|
|
5154
|
+
|
|
5155
|
+
const monthly = Array.from(monthMap.values())
|
|
5156
|
+
monthly.forEach((m) => {
|
|
5157
|
+
const total = totalByMonth.get(m.period) || 1
|
|
5158
|
+
m.outsideWorkRate = m.outsideWorkCount / total
|
|
5159
|
+
})
|
|
5160
|
+
|
|
5161
|
+
return monthly.sort((a, b) => a.period.localeCompare(b.period))
|
|
5162
|
+
}
|
|
5163
|
+
|
|
5164
|
+
/**
|
|
5165
|
+
* 根据 commits 计算每日最晚提交时间(所有工作时间外提交的最晚时刻)
|
|
5166
|
+
* 与后端逻辑保持一致:只看小时部分,忽略分钟
|
|
5167
|
+
*/
|
|
5168
|
+
function computeLatestByDay(
|
|
5169
|
+
commits,
|
|
5170
|
+
startHour,
|
|
5171
|
+
endHour,
|
|
5172
|
+
cutoff,
|
|
5173
|
+
lunchStart,
|
|
5174
|
+
lunchEnd
|
|
5175
|
+
) {
|
|
5176
|
+
const cutoffHour = cutoff || 6
|
|
5177
|
+
|
|
5178
|
+
// 第一步:按日期分组所有 commits(使用本地时间日期,避免时区偏移)
|
|
5179
|
+
const dayGroups = {}
|
|
5180
|
+
commits.forEach((c) => {
|
|
5181
|
+
const d = new Date(c.date)
|
|
5182
|
+
if (isNaN(d.getTime())) return
|
|
5183
|
+
|
|
5184
|
+
const dateStr = formatDateYMD(d) // YYYY-MM-DD 本地时间
|
|
5185
|
+
if (!dayGroups[dateStr]) {
|
|
5186
|
+
dayGroups[dateStr] = []
|
|
5187
|
+
}
|
|
5188
|
+
dayGroups[dateStr].push(c)
|
|
5189
|
+
})
|
|
5190
|
+
|
|
5191
|
+
const dayKeys = Object.keys(dayGroups).sort()
|
|
5192
|
+
|
|
5193
|
+
// 第二步:找出虚拟日期(次日凌晨有提交但前一日无记录)
|
|
5194
|
+
const virtualPrevDays = new Set()
|
|
5195
|
+
commits.forEach((c) => {
|
|
5196
|
+
const d = new Date(c.date)
|
|
5197
|
+
if (isNaN(d.getTime())) return
|
|
5198
|
+
|
|
5199
|
+
const h = d.getHours()
|
|
5200
|
+
// 只看凌晨 [0, cutoff) 且 < startHour 的提交
|
|
5201
|
+
if (h < 0 || h >= cutoffHour || h >= startHour) return
|
|
5202
|
+
|
|
5203
|
+
const curDay = formatDateYMD(d)
|
|
5204
|
+
// 计算前一天(本地日期)
|
|
5205
|
+
const prevDate = new Date(d)
|
|
5206
|
+
prevDate.setDate(prevDate.getDate() - 1)
|
|
5207
|
+
const prevDay = formatDateYMD(prevDate)
|
|
5208
|
+
|
|
5209
|
+
// 如果前一日没有任何提交记录,则添加虚拟日期
|
|
5210
|
+
if (!dayGroups[prevDay]) {
|
|
5211
|
+
virtualPrevDays.add(prevDay)
|
|
5212
|
+
}
|
|
5213
|
+
})
|
|
5214
|
+
|
|
5215
|
+
// 第三步:合并所有日期(实际 + 虚拟)
|
|
5216
|
+
const allDayKeys = Array.from(
|
|
5217
|
+
new Set([...dayKeys, ...virtualPrevDays])
|
|
5218
|
+
).sort()
|
|
5219
|
+
|
|
5220
|
+
// 第四步:计算每一天的最晚提交时间
|
|
5221
|
+
const latestByDay = allDayKeys.map((k) => {
|
|
5222
|
+
const list = dayGroups[k] || []
|
|
5223
|
+
|
|
5224
|
+
// 1) 当天下班后的提交小时:>= endHour 且 < 24
|
|
5225
|
+
const sameDayHours = list
|
|
5226
|
+
.map((c) => new Date(c.date))
|
|
5227
|
+
.filter((d) => !isNaN(d.getTime()))
|
|
5228
|
+
.map((d) => d.getHours())
|
|
5229
|
+
.filter((h) => h >= endHour && h < 24)
|
|
5230
|
+
|
|
5231
|
+
// 2) 次日凌晨的提交小时:在 [0, cutoffHour) 且 < startHour
|
|
5232
|
+
// 构造次日的本地日期键(避免使用 UTC)
|
|
5233
|
+
const nextDate = new Date(`${k}T00:00:00`)
|
|
5234
|
+
nextDate.setDate(nextDate.getDate() + 1)
|
|
5235
|
+
const nextKey = formatDateYMD(nextDate)
|
|
5236
|
+
const early = dayGroups[nextKey] || []
|
|
5237
|
+
const earlyHours = early
|
|
5238
|
+
.map((c) => new Date(c.date))
|
|
5239
|
+
.filter((d) => !isNaN(d.getTime()))
|
|
5240
|
+
.map((d) => d.getHours())
|
|
5241
|
+
.filter((h) => h >= 0 && h < cutoffHour && h < startHour)
|
|
5242
|
+
|
|
5243
|
+
// 3) 合并时间值:当天用原始小时,次日凌晨用 24+小时
|
|
5244
|
+
const overtimeValues = [...sameDayHours, ...earlyHours.map((h) => 24 + h)]
|
|
5245
|
+
|
|
5246
|
+
// 如果没有任何下班后的提交,返回 null
|
|
5247
|
+
if (overtimeValues.length === 0) {
|
|
5248
|
+
return {
|
|
5249
|
+
date: k,
|
|
5250
|
+
latestHour: null,
|
|
5251
|
+
latestHourNormalized: null
|
|
5252
|
+
}
|
|
5253
|
+
}
|
|
5254
|
+
|
|
5255
|
+
const latestHourNormalized = Math.max(...overtimeValues)
|
|
5256
|
+
const sameDayMax =
|
|
5257
|
+
sameDayHours.length > 0 ? Math.max(...sameDayHours) : null
|
|
5258
|
+
|
|
5259
|
+
return {
|
|
5260
|
+
date: k,
|
|
5261
|
+
latestHour: sameDayMax,
|
|
5262
|
+
latestHourNormalized
|
|
5263
|
+
}
|
|
5264
|
+
})
|
|
5265
|
+
|
|
5266
|
+
return latestByDay
|
|
5267
|
+
}
|
|
5268
|
+
|
|
5269
|
+
async function main() {
|
|
5270
|
+
const { commits, config, authorChanges, options } = await loadData()
|
|
2406
5271
|
commitsAll = commits
|
|
2407
5272
|
filtered = commitsAll.slice()
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
5273
|
+
|
|
5274
|
+
// 保存所有 commits 数据供小时分布图使用
|
|
5275
|
+
window.__allCommitsData = commits
|
|
5276
|
+
|
|
5277
|
+
// 保存采样起止(来自 /data/options.mjs 中的 period / serve 参数)供前端展示
|
|
5278
|
+
// 可能的形式:--since YYYY-MM-DD --until YYYY-MM-DD
|
|
5279
|
+
const period = options?.period || {}
|
|
5280
|
+
window.__samplingSince = period?.since || null
|
|
5281
|
+
window.__samplingUntil = period?.until || null
|
|
5282
|
+
// 格式化并保存采样的作者筛选信息(支持 string / { include:[], exclude:[] })
|
|
5283
|
+
window.__samplingAuthor = formatAuthorFilter(options?.author || null)
|
|
5284
|
+
window.__config = config
|
|
5285
|
+
|
|
5286
|
+
// 前端计算 overtime 数据
|
|
5287
|
+
const startHour = config.startHour ?? 9
|
|
5288
|
+
const endHour = config.endHour ?? 18
|
|
5289
|
+
const lunchStart = config.lunchStart ?? 12
|
|
5290
|
+
const lunchEnd = config.lunchEnd ?? 14
|
|
5291
|
+
const cutoff = config.overnightCutoff ?? 6
|
|
5292
|
+
|
|
5293
|
+
const stats = computeHourlyOvertime(commits, {
|
|
5294
|
+
startHour,
|
|
5295
|
+
endHour,
|
|
5296
|
+
lunchStart,
|
|
5297
|
+
lunchEnd
|
|
5298
|
+
})
|
|
5299
|
+
|
|
5300
|
+
const weekly = computeWeeklyOvertime(
|
|
5301
|
+
commits,
|
|
5302
|
+
startHour,
|
|
5303
|
+
endHour,
|
|
5304
|
+
cutoff,
|
|
5305
|
+
lunchStart,
|
|
5306
|
+
lunchEnd
|
|
5307
|
+
)
|
|
5308
|
+
const monthly = computeMonthlyOvertime(
|
|
5309
|
+
commits,
|
|
5310
|
+
startHour,
|
|
5311
|
+
endHour,
|
|
5312
|
+
cutoff,
|
|
5313
|
+
lunchStart,
|
|
5314
|
+
lunchEnd
|
|
5315
|
+
)
|
|
5316
|
+
const latestByDay = computeLatestByDay(
|
|
5317
|
+
commits,
|
|
5318
|
+
startHour,
|
|
5319
|
+
endHour,
|
|
5320
|
+
cutoff,
|
|
5321
|
+
lunchStart,
|
|
5322
|
+
lunchEnd
|
|
5323
|
+
)
|
|
5324
|
+
|
|
5325
|
+
window.__overtimeEndHour = endHour
|
|
5326
|
+
window.__overnightCutoff = cutoff
|
|
5327
|
+
window.__lunchStart = lunchStart
|
|
5328
|
+
window.__lunchEnd = lunchEnd
|
|
5329
|
+
|
|
2414
5330
|
initTableControls()
|
|
2415
5331
|
updatePager()
|
|
2416
5332
|
renderCommitsTablePage()
|
|
@@ -2447,10 +5363,34 @@ async function main() {
|
|
|
2447
5363
|
console.log('最累的一天:', daily.analysis.mostTiredDay)
|
|
2448
5364
|
|
|
2449
5365
|
drawChangeTrends(authorChanges)
|
|
5366
|
+
// 开发者 累计提交次数(按日/周/月/年)
|
|
5367
|
+
drawAuthorTotalCommitsTrends(commits)
|
|
5368
|
+
// 开发者 累计提交Changed(按日/周/月/年)
|
|
5369
|
+
drawAuthorTotalCommitsChangedTrends(commits)
|
|
2450
5370
|
drawAuthorOvertimeTrends(commits, stats)
|
|
2451
5371
|
drawAuthorLatestOvertimeTrends(commits, stats)
|
|
5372
|
+
drawAuthorLunchTrends(commits, stats)
|
|
5373
|
+
// 新增:开发者累计午休时长(按日/周/月/年)
|
|
5374
|
+
drawAuthorTotalLunchTimeTrends(commits, stats)
|
|
5375
|
+
// 新增:开发者累计加班时长(按日/周/月/年)
|
|
5376
|
+
drawAuthorTotalOvertimeTrends(commits, stats)
|
|
2452
5377
|
computeAndRenderLatestOvertime(latestByDay)
|
|
2453
5378
|
renderKpi(stats)
|
|
5379
|
+
|
|
5380
|
+
const chartsContainer = document.getElementById('main')
|
|
5381
|
+
let ticking = false
|
|
5382
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
5383
|
+
if (!ticking) {
|
|
5384
|
+
// 只要 #charts 容器大小变了(无论什么原因),都会执行
|
|
5385
|
+
window.requestAnimationFrame(() => {
|
|
5386
|
+
chartInstances.forEach((chart) => chart.resize())
|
|
5387
|
+
ticking = false
|
|
5388
|
+
})
|
|
5389
|
+
ticking = true
|
|
5390
|
+
}
|
|
5391
|
+
})
|
|
5392
|
+
|
|
5393
|
+
resizeObserver.observe(chartsContainer)
|
|
2454
5394
|
}
|
|
2455
5395
|
|
|
2456
5396
|
// 抽屉关闭交互(按钮 + 点击遮罩)
|