wukong-gitlog-cli 1.0.38 → 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 +103 -0
- package/README.md +93 -173
- package/README.zh-CN.md +85 -137
- package/doc//347/233/256/345/275/225/347/273/223/346/236/204.md +2871 -0
- package/package.json +33 -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 +3233 -260
- package/web/index.html +175 -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 +155 -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,60 @@
|
|
|
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
|
+
|
|
42
|
+
function filterByDate(commits) {
|
|
43
|
+
const start = document.getElementById('startDate')?.value
|
|
44
|
+
const end = document.getElementById('endDate')?.value
|
|
45
|
+
|
|
46
|
+
if (!start && !end) return commits
|
|
47
|
+
|
|
48
|
+
const startTime = start ? new Date(`${start}T00:00:00`).getTime() : -Infinity
|
|
49
|
+
|
|
50
|
+
const endTime = end ? new Date(`${end}T23:59:59`).getTime() : Infinity
|
|
51
|
+
|
|
52
|
+
return commits.filter((c) => {
|
|
53
|
+
const t = new Date(c.date).getTime()
|
|
54
|
+
return t >= startTime && t <= endTime
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
6
58
|
// ISO 周 key:YYYY-Www
|
|
7
59
|
function getIsoWeekKey(dStr) {
|
|
8
60
|
const d = new Date(dStr)
|
|
@@ -23,6 +75,38 @@ function formatDateYMD(d) {
|
|
|
23
75
|
return `${yyyy}-${mm}-${dd}`
|
|
24
76
|
}
|
|
25
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
|
+
|
|
26
110
|
function getISOWeekRange(isoYear, isoWeek) {
|
|
27
111
|
// 找到 ISO 年的第一个周一
|
|
28
112
|
// ISO 年的第 1 周包含 1 月 4 日
|
|
@@ -45,44 +129,27 @@ function getISOWeekRange(isoYear, isoWeek) {
|
|
|
45
129
|
}
|
|
46
130
|
|
|
47
131
|
async function loadData() {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
authorChangesModule
|
|
57
|
-
] = await Promise.all([
|
|
58
|
-
import('/data/commits.mjs'),
|
|
59
|
-
import('/data/overtime-stats.mjs'),
|
|
60
|
-
import('/data/overtime-weekly.mjs'),
|
|
61
|
-
import('/data/overtime-monthly.mjs').catch(() => ({ default: [] })),
|
|
62
|
-
import('/data/overtime-latest-by-day.mjs').catch(() => ({ default: [] })),
|
|
63
|
-
import('/data/config.mjs').catch(() => ({ default: {} })),
|
|
64
|
-
import('/data/author-changes.mjs').catch(() => ({ default: {} }))
|
|
65
|
-
])
|
|
66
|
-
const commits = commitsModule.default || []
|
|
67
|
-
const stats = statsModule.default || {}
|
|
68
|
-
const weekly = weeklyModule.default || []
|
|
69
|
-
const monthly = monthlyModule.default || []
|
|
70
|
-
const latestByDay = latestByDayModule.default || []
|
|
71
|
-
const config = configModule.default || {}
|
|
72
|
-
const authorChanges = authorChangesModule.default || {}
|
|
73
|
-
return {
|
|
74
|
-
commits,
|
|
75
|
-
stats,
|
|
76
|
-
weekly,
|
|
77
|
-
monthly,
|
|
78
|
-
latestByDay,
|
|
79
|
-
config,
|
|
80
|
-
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
|
|
81
140
|
}
|
|
82
|
-
} catch (err) {
|
|
83
|
-
console.error('Load data failed', err)
|
|
84
|
-
return { commits: [], stats: {}, weekly: [], monthly: [], latestByDay: [] }
|
|
85
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 }
|
|
86
153
|
}
|
|
87
154
|
|
|
88
155
|
let commitsAll = []
|
|
@@ -95,12 +162,17 @@ function renderCommitsTablePage() {
|
|
|
95
162
|
tbody.innerHTML = ''
|
|
96
163
|
const start = (page - 1) * pageSize
|
|
97
164
|
const end = start + pageSize
|
|
165
|
+
if(!window.__config.git.numstat) {
|
|
166
|
+
document.getElementById('thChanged').style.display = 'none'
|
|
167
|
+
}
|
|
98
168
|
filtered.slice(start, end).forEach((c) => {
|
|
99
169
|
const tr = document.createElement('tr')
|
|
100
|
-
|
|
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}`
|
|
101
172
|
tbody.appendChild(tr)
|
|
102
173
|
})
|
|
103
|
-
document.getElementById('commitsTotal').textContent =
|
|
174
|
+
document.getElementById('commitsTotal').textContent =
|
|
175
|
+
`共${filtered.length}条记录`
|
|
104
176
|
}
|
|
105
177
|
|
|
106
178
|
function updatePager() {
|
|
@@ -115,16 +187,25 @@ function updatePager() {
|
|
|
115
187
|
function applySearch() {
|
|
116
188
|
const q = document.getElementById('searchInput').value.trim().toLowerCase()
|
|
117
189
|
|
|
190
|
+
// ① 先做日期过滤
|
|
191
|
+
const base = filterByDate(commitsAll)
|
|
192
|
+
|
|
118
193
|
if (!q) {
|
|
119
|
-
filtered =
|
|
194
|
+
filtered = base.slice()
|
|
120
195
|
} else {
|
|
121
|
-
filtered =
|
|
196
|
+
filtered = base.filter((c) => {
|
|
122
197
|
const h = c.hash.toLowerCase()
|
|
123
198
|
const a = String(c.author || '').toLowerCase()
|
|
124
199
|
const e = String(c.email || '').toLowerCase()
|
|
125
200
|
const m = String(c.message || '').toLowerCase()
|
|
126
201
|
const d = formatDate(c.date).toLowerCase()
|
|
127
|
-
return
|
|
202
|
+
return (
|
|
203
|
+
h.includes(q) ||
|
|
204
|
+
a.includes(q) ||
|
|
205
|
+
e.includes(q) ||
|
|
206
|
+
m.includes(q) ||
|
|
207
|
+
d.includes(q)
|
|
208
|
+
)
|
|
128
209
|
})
|
|
129
210
|
}
|
|
130
211
|
page = 1
|
|
@@ -134,6 +215,13 @@ function applySearch() {
|
|
|
134
215
|
|
|
135
216
|
function initTableControls() {
|
|
136
217
|
document.getElementById('searchInput').addEventListener('input', applySearch)
|
|
218
|
+
document.getElementById('startDate')?.addEventListener('change', applySearch)
|
|
219
|
+
document.getElementById('endDate')?.addEventListener('change', applySearch)
|
|
220
|
+
document.getElementById('clearDate')?.addEventListener('click', () => {
|
|
221
|
+
document.getElementById('startDate').value = ''
|
|
222
|
+
document.getElementById('endDate').value = ''
|
|
223
|
+
applySearch()
|
|
224
|
+
})
|
|
137
225
|
document.getElementById('pageSize').addEventListener('change', (e) => {
|
|
138
226
|
pageSize = parseInt(e.target.value, 10) || 10
|
|
139
227
|
page = 1
|
|
@@ -159,28 +247,61 @@ function initTableControls() {
|
|
|
159
247
|
|
|
160
248
|
function drawHourlyOvertime(stats, onHourClick) {
|
|
161
249
|
const el = document.getElementById('hourlyOvertimeChart')
|
|
250
|
+
|
|
251
|
+
const isEmpty = hideElementByObj({ el, objectName: stats })
|
|
252
|
+
if (isEmpty) {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
162
255
|
const chart = echarts.init(el)
|
|
256
|
+
chartInstances.push(chart)
|
|
163
257
|
|
|
164
|
-
|
|
165
|
-
const
|
|
258
|
+
// 显示所有提交数(不仅仅是加班)
|
|
259
|
+
const allCommits = Array(24).fill(0)
|
|
166
260
|
const labels = Array.from({ length: 24 }, (_, i) =>
|
|
167
261
|
String(i).padStart(2, '0')
|
|
168
262
|
)
|
|
169
263
|
|
|
170
|
-
//
|
|
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
|
+
// 颜色逻辑:根据时间段着色
|
|
171
277
|
function getColor(h) {
|
|
172
|
-
|
|
173
|
-
if (h
|
|
174
|
-
|
|
175
|
-
if (h >= stats.startHour && h < stats.
|
|
176
|
-
|
|
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'
|
|
177
292
|
}
|
|
178
293
|
|
|
179
|
-
const data =
|
|
294
|
+
const data = allCommits.map((v, h) => ({
|
|
180
295
|
value: v,
|
|
181
296
|
itemStyle: { color: getColor(h) }
|
|
182
297
|
}))
|
|
183
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
|
+
|
|
184
305
|
chart.setOption({
|
|
185
306
|
tooltip: {
|
|
186
307
|
trigger: 'axis',
|
|
@@ -188,11 +309,31 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
188
309
|
const p = params[0]
|
|
189
310
|
const h = parseInt(p.axisValue, 10)
|
|
190
311
|
const count = p.value
|
|
191
|
-
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
|
+
|
|
192
332
|
return `
|
|
193
333
|
🕒 <b>${h}:00</b><br/>
|
|
194
334
|
提交次数:<b>${count}</b><br/>
|
|
195
|
-
占全天比例:<b>${
|
|
335
|
+
占全天比例:<b>${percent}%</b><br/>
|
|
336
|
+
时段:${period}
|
|
196
337
|
`
|
|
197
338
|
}
|
|
198
339
|
},
|
|
@@ -214,7 +355,7 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
214
355
|
series: [
|
|
215
356
|
{
|
|
216
357
|
type: 'bar',
|
|
217
|
-
name: '
|
|
358
|
+
name: '每小时提交',
|
|
218
359
|
data,
|
|
219
360
|
barWidth: 18,
|
|
220
361
|
|
|
@@ -227,7 +368,7 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
227
368
|
name: '最晚提交',
|
|
228
369
|
coord: [
|
|
229
370
|
String(stats.latestCommitHour).padStart(2, '0'),
|
|
230
|
-
|
|
371
|
+
allCommits[stats.latestCommitHour] || 0
|
|
231
372
|
]
|
|
232
373
|
}
|
|
233
374
|
]
|
|
@@ -274,14 +415,21 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
274
415
|
}
|
|
275
416
|
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
276
417
|
if (Number.isNaN(hour)) return
|
|
277
|
-
|
|
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)
|
|
278
426
|
})
|
|
279
427
|
}
|
|
280
428
|
|
|
281
429
|
return chart
|
|
282
430
|
}
|
|
283
431
|
|
|
284
|
-
//
|
|
432
|
+
// 每小时加班分布
|
|
285
433
|
function showSideBarForHour({ hour, commitsOrCount, titleDrawer }) {
|
|
286
434
|
// 支持传入 number(仅次数)或 array(详细 commit 列表)
|
|
287
435
|
// 统一复用通用详情侧栏 DOM
|
|
@@ -311,14 +459,14 @@ function showSideBarForHour({ hour, commitsOrCount, titleDrawer }) {
|
|
|
311
459
|
// commits 列表:展示作者/时间/消息(最多前 50 条,避免性能问题)
|
|
312
460
|
const commits = commitsOrCount.slice(0, 50)
|
|
313
461
|
contentEl.innerHTML = `<div class="sidebar-list">${commits
|
|
314
|
-
.map((c) => {
|
|
462
|
+
.map((c, index) => {
|
|
315
463
|
const author = c.author ?? c.name ?? 'unknown'
|
|
316
464
|
const time = c.date ?? c.time ?? ''
|
|
317
465
|
const msg = (c.message ?? c.msg ?? c.body ?? '').replace(/\n/g, ' ')
|
|
318
466
|
return `
|
|
319
467
|
<div class="sidebar-item">
|
|
320
468
|
<div class="sidebar-item-header">
|
|
321
|
-
<span class="author"
|
|
469
|
+
<span class="author">${index + 1}. 👤 ${escapeHtml(author)}</span>
|
|
322
470
|
<span class="time">🕒 ${escapeHtml(time)}</span>
|
|
323
471
|
</div>
|
|
324
472
|
<div class="sidebar-item-message">${escapeHtml(msg)}</div>
|
|
@@ -350,30 +498,121 @@ function escapeHtml(str = '') {
|
|
|
350
498
|
.replaceAll("'", ''')
|
|
351
499
|
}
|
|
352
500
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
+
|
|
360
530
|
chart.setOption({
|
|
361
|
-
|
|
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
|
+
|
|
362
574
|
series: [
|
|
363
575
|
{
|
|
364
576
|
type: 'pie',
|
|
365
|
-
radius: '
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
370
588
|
}
|
|
371
589
|
]
|
|
372
590
|
})
|
|
591
|
+
|
|
373
592
|
return chart
|
|
374
593
|
}
|
|
375
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
|
+
|
|
376
613
|
function drawDailyTrend(commits, onDayClick) {
|
|
614
|
+
const el = document.getElementById('dailyTrendChart')
|
|
615
|
+
|
|
377
616
|
if (!Array.isArray(commits) || commits.length === 0) return null
|
|
378
617
|
|
|
379
618
|
// 聚合每日提交数量
|
|
@@ -386,11 +625,11 @@ function drawDailyTrend(commits, onDayClick) {
|
|
|
386
625
|
const labels = Array.from(map.keys()).sort()
|
|
387
626
|
const data = labels.map((l) => map.get(l))
|
|
388
627
|
|
|
389
|
-
const el = document.getElementById('dailyTrendChart')
|
|
390
628
|
const titleDrawer = el.getAttribute('data-title') || ''
|
|
391
629
|
|
|
392
630
|
// eslint-disable-next-line no-undef
|
|
393
631
|
const chart = echarts.init(el)
|
|
632
|
+
chartInstances.push(chart)
|
|
394
633
|
|
|
395
634
|
chart.setOption({
|
|
396
635
|
tooltip: {
|
|
@@ -517,14 +756,14 @@ function showSideBarForWeek({ period, weeklyItem, commits = [], titleDrawer }) {
|
|
|
517
756
|
html += `<div style="padding:10px;color:#777;">该周无提交记录</div>`
|
|
518
757
|
} else {
|
|
519
758
|
html += `<div class="sidebar-list">${commits
|
|
520
|
-
.map((c) => {
|
|
759
|
+
.map((c, index) => {
|
|
521
760
|
const author = escapeHtml(c.author || 'unknown')
|
|
522
761
|
const time = escapeHtml(c.date || '')
|
|
523
762
|
const msg = escapeHtml((c.message || '').replace(/\n/g, ' '))
|
|
524
763
|
return `
|
|
525
764
|
<div class="sidebar-item">
|
|
526
765
|
<div class="sidebar-item-header">
|
|
527
|
-
<span class="author"
|
|
766
|
+
<span class="author">${index + 1}👤 ${author}</span>
|
|
528
767
|
<span class="time">🕒 ${time}</span>
|
|
529
768
|
</div>
|
|
530
769
|
<div class="sidebar-item-message">${msg}</div>
|
|
@@ -540,16 +779,23 @@ function showSideBarForWeek({ period, weeklyItem, commits = [], titleDrawer }) {
|
|
|
540
779
|
}
|
|
541
780
|
|
|
542
781
|
function drawWeeklyTrend(weekly, commits, onWeekClick) {
|
|
543
|
-
|
|
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
|
+
}
|
|
544
790
|
|
|
545
791
|
const labels = weekly.map((w) => w.period)
|
|
546
792
|
const dataRate = weekly.map((w) => +(w.outsideWorkRate * 100).toFixed(1)) // %
|
|
547
793
|
const dataCount = weekly.map((w) => w.outsideWorkCount)
|
|
548
794
|
|
|
549
|
-
const el = document.getElementById('weeklyTrendChart')
|
|
550
795
|
const titleDrawer = el.getAttribute('data-title') || ''
|
|
551
796
|
|
|
552
797
|
const chart = echarts.init(el)
|
|
798
|
+
chartInstances.push(chart)
|
|
553
799
|
|
|
554
800
|
chart.setOption({
|
|
555
801
|
tooltip: {
|
|
@@ -665,15 +911,20 @@ function drawWeeklyTrend(weekly, commits, onWeekClick) {
|
|
|
665
911
|
}
|
|
666
912
|
|
|
667
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
|
+
}
|
|
668
919
|
if (!Array.isArray(monthly) || monthly.length === 0) return null
|
|
669
920
|
|
|
670
921
|
const labels = monthly.map((m) => m.period)
|
|
671
922
|
const dataRate = monthly.map((m) => +(m.outsideWorkRate * 100).toFixed(1)) // 0–100%
|
|
672
923
|
|
|
673
|
-
const el = document.getElementById('monthlyTrendChart')
|
|
674
924
|
const titleDrawer = el.getAttribute('data-title') || ''
|
|
675
925
|
// eslint-disable-next-line no-undef
|
|
676
926
|
const chart = echarts.init(el)
|
|
927
|
+
chartInstances.push(chart)
|
|
677
928
|
|
|
678
929
|
chart.setOption({
|
|
679
930
|
tooltip: {
|
|
@@ -788,6 +1039,11 @@ function drawMonthlyTrend(monthly, commits, onMonthClick) {
|
|
|
788
1039
|
}
|
|
789
1040
|
|
|
790
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
|
+
}
|
|
791
1047
|
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
|
|
792
1048
|
|
|
793
1049
|
const labels = latestByDay.map((d) => d.date)
|
|
@@ -816,11 +1072,11 @@ function drawLatestHourDaily(latestByDay, commits, onDayClick) {
|
|
|
816
1072
|
const numericValues = raw.filter((v) => typeof v === 'number')
|
|
817
1073
|
const maxV = numericValues.length > 0 ? Math.max(...numericValues) : 0
|
|
818
1074
|
|
|
819
|
-
const el = document.getElementById('latestHourDailyChart')
|
|
820
1075
|
const titleDrawer = el.getAttribute('data-title') || ''
|
|
821
1076
|
|
|
822
1077
|
// eslint-disable-next-line no-undef
|
|
823
1078
|
const chart = echarts.init(el)
|
|
1079
|
+
chartInstances.push(chart)
|
|
824
1080
|
|
|
825
1081
|
chart.setOption({
|
|
826
1082
|
tooltip: {
|
|
@@ -932,6 +1188,11 @@ function drawLatestHourDaily(latestByDay, commits, onDayClick) {
|
|
|
932
1188
|
}
|
|
933
1189
|
|
|
934
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
|
+
}
|
|
935
1196
|
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
|
|
936
1197
|
|
|
937
1198
|
const labels = latestByDay.map((d) => d.date)
|
|
@@ -947,11 +1208,11 @@ function drawDailySeverity(latestByDay, commits, onDayClick) {
|
|
|
947
1208
|
// 这里按 0 小时加班处理,保证折线连续。
|
|
948
1209
|
const sev = raw.map((v) => (v == null ? 0 : Math.max(0, Number(v) - endH)))
|
|
949
1210
|
|
|
950
|
-
const el = document.getElementById('dailySeverityChart')
|
|
951
1211
|
const titleDrawer = el.getAttribute('data-title') || ''
|
|
952
1212
|
|
|
953
1213
|
// eslint-disable-next-line no-undef
|
|
954
1214
|
const chart = echarts.init(el)
|
|
1215
|
+
chartInstances.push(chart)
|
|
955
1216
|
|
|
956
1217
|
chart.setOption({
|
|
957
1218
|
tooltip: {
|
|
@@ -1105,7 +1366,7 @@ function drawDailyTrendSeverity(commits, weekly, onDayClick) {
|
|
|
1105
1366
|
|
|
1106
1367
|
// ---------- 3. 自动分析「最累的一周」 ----------
|
|
1107
1368
|
let maxWeek = null
|
|
1108
|
-
if (Array.isArray(weekly)) {
|
|
1369
|
+
if (Array.isArray(weekly) && weekly.length > 0) {
|
|
1109
1370
|
maxWeek = weekly.reduce((a, b) =>
|
|
1110
1371
|
a.outsideWorkCount > b.outsideWorkCount ? a : b
|
|
1111
1372
|
)
|
|
@@ -1153,24 +1414,26 @@ function drawDailyTrendSeverity(commits, weekly, onDayClick) {
|
|
|
1153
1414
|
const count = params?.[0].value
|
|
1154
1415
|
const details = dayCommitsDetail[date] || []
|
|
1155
1416
|
|
|
1156
|
-
let html = `📅 <b>${date}</b><br/>提交次数:${count}<br
|
|
1417
|
+
let html = `📅 <b>${date}</b><br/>提交次数:${count}<br/>`
|
|
1157
1418
|
|
|
1158
1419
|
details.slice(0, 5).forEach((d) => {
|
|
1159
|
-
html += `👤 ${d.author}<br/>🕒 ${d.time}<br
|
|
1420
|
+
html += `👤 ${d.author}<br/>🕒 ${d.time}<br/> <div class="long-txt-break-all">💬${d.msg}</div>`
|
|
1160
1421
|
})
|
|
1161
1422
|
|
|
1162
1423
|
if (details.length > 5) {
|
|
1163
1424
|
html += `(其余 ${details.length - 5} 条已省略)`
|
|
1164
1425
|
}
|
|
1165
|
-
|
|
1426
|
+
html = `<div class="tooltip-box">${html}</div>`
|
|
1166
1427
|
return html
|
|
1167
1428
|
}
|
|
1168
1429
|
|
|
1169
1430
|
// ---------- 7. 绘图 ----------
|
|
1170
1431
|
const el = document.getElementById('dailyTrendChartDog')
|
|
1432
|
+
|
|
1171
1433
|
const titleDrawer = el.getAttribute('data-title') || ''
|
|
1172
1434
|
|
|
1173
1435
|
const chart = echarts.init(el)
|
|
1436
|
+
chartInstances.push(chart)
|
|
1174
1437
|
|
|
1175
1438
|
chart.setOption({
|
|
1176
1439
|
tooltip: {
|
|
@@ -1243,10 +1506,10 @@ function showDayDetailSidebar({ date, count, commits, titleDrawer }) {
|
|
|
1243
1506
|
// 渲染详情
|
|
1244
1507
|
content.innerHTML = commits
|
|
1245
1508
|
.map(
|
|
1246
|
-
(c) => `
|
|
1509
|
+
(c, index) => `
|
|
1247
1510
|
<div class="sidebar-item">
|
|
1248
1511
|
<div class="sidebar-item-header">
|
|
1249
|
-
<span class="author"
|
|
1512
|
+
<span class="author">${index + 1}👤 ${escapeHtml(c.author || 'unknown')}</span>
|
|
1250
1513
|
<span class="time">🕒 ${escapeHtml(c.time || c.date || '')}</span>
|
|
1251
1514
|
</div>
|
|
1252
1515
|
<div class="sidebar-item-message">${escapeHtml(c.msg || c.message || '')}</div>
|
|
@@ -1312,14 +1575,59 @@ function renderKpi(stats) {
|
|
|
1312
1575
|
(latestOut ? new Date(latestOut.date).getHours() : null)
|
|
1313
1576
|
}
|
|
1314
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
|
+
|
|
1315
1603
|
const html = [
|
|
1316
|
-
|
|
1604
|
+
htmlLatest,
|
|
1317
1605
|
`<div class="hr"></div>`,
|
|
1318
|
-
`<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>`,
|
|
1319
1607
|
`<div class="hr"></div>`,
|
|
1320
|
-
`<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div
|
|
1608
|
+
`<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div>`,
|
|
1609
|
+
samplingHtml
|
|
1321
1610
|
].join('')
|
|
1322
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
|
+
}
|
|
1323
1631
|
}
|
|
1324
1632
|
|
|
1325
1633
|
// 1) 按小时分组(例:commits 为原始提交数组)
|
|
@@ -1445,8 +1753,15 @@ function buildDataset(stats, type) {
|
|
|
1445
1753
|
|
|
1446
1754
|
const drawChangeTrends = (stats) => {
|
|
1447
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
|
+
}
|
|
1448
1762
|
if (!el) return null
|
|
1449
1763
|
const chart = echarts.init(el)
|
|
1764
|
+
chartInstances.push(chart)
|
|
1450
1765
|
|
|
1451
1766
|
function render(type) {
|
|
1452
1767
|
const { authors, allPeriods, series } = buildDataset(stats, type)
|
|
@@ -1476,12 +1791,16 @@ const drawChangeTrends = (stats) => {
|
|
|
1476
1791
|
// extra = `<div style="margin-top:4px;color:#999;font-size:12px">
|
|
1477
1792
|
// 周区间:${start} ~ ${end}
|
|
1478
1793
|
// </div>`
|
|
1479
|
-
// TODO: remove debug log before production
|
|
1480
1794
|
extra = ''
|
|
1481
1795
|
}
|
|
1482
1796
|
|
|
1483
1797
|
const lines = params
|
|
1484
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
|
+
)
|
|
1485
1804
|
.map(
|
|
1486
1805
|
(item) => `${item.marker}${item.seriesName}: ${item.data} 行变更`
|
|
1487
1806
|
)
|
|
@@ -1514,6 +1833,61 @@ const drawChangeTrends = (stats) => {
|
|
|
1514
1833
|
})
|
|
1515
1834
|
})
|
|
1516
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
|
+
|
|
1517
1891
|
return chart
|
|
1518
1892
|
}
|
|
1519
1893
|
|
|
@@ -1562,6 +1936,7 @@ function drawAuthorOvertimeTrends(commits, stats) {
|
|
|
1562
1936
|
const el = document.getElementById('chartAuthorOvertime')
|
|
1563
1937
|
if (!el) return null
|
|
1564
1938
|
const chart = echarts.init(el)
|
|
1939
|
+
chartInstances.push(chart)
|
|
1565
1940
|
|
|
1566
1941
|
const startHour =
|
|
1567
1942
|
typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
@@ -1609,6 +1984,11 @@ function drawAuthorOvertimeTrends(commits, stats) {
|
|
|
1609
1984
|
|
|
1610
1985
|
const lines = params
|
|
1611
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
|
+
)
|
|
1612
1992
|
.map(
|
|
1613
1993
|
(item) => `${item.marker}${item.seriesName}: ${item.data} 次提交`
|
|
1614
1994
|
)
|
|
@@ -1643,13 +2023,75 @@ function drawAuthorOvertimeTrends(commits, stats) {
|
|
|
1643
2023
|
})
|
|
1644
2024
|
})
|
|
1645
2025
|
|
|
1646
|
-
//
|
|
2026
|
+
// 输出本周风险与加班时长排行
|
|
1647
2027
|
renderWeeklyRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1648
2028
|
renderMonthlyRiskSummary(commits, { startHour, endHour, cutoff })
|
|
2029
|
+
// 新增:本周/本月加班时长排名(显示所有作者总时长,前三名带图标,状元标注“夜魔侠”)
|
|
2030
|
+
renderWeeklyDurationRankSummary(commits, { startHour, endHour, cutoff })
|
|
1649
2031
|
renderWeeklyDurationRiskSummary(commits, { startHour, endHour, cutoff })
|
|
2032
|
+
renderMonthlyDurationRankSummary(commits, { startHour, endHour, cutoff })
|
|
1650
2033
|
renderMonthlyDurationRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1651
2034
|
renderRolling30DurationRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1652
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
|
+
|
|
1653
2095
|
return chart
|
|
1654
2096
|
}
|
|
1655
2097
|
|
|
@@ -1737,6 +2179,65 @@ function renderWeeklyRiskSummary(
|
|
|
1737
2179
|
)
|
|
1738
2180
|
}
|
|
1739
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
|
+
|
|
1740
2241
|
box.innerHTML = `
|
|
1741
2242
|
<div class="risk-summary">
|
|
1742
2243
|
<div class="risk-title">【本周风险总结】</div>
|
|
@@ -1747,6 +2248,16 @@ function renderWeeklyRiskSummary(
|
|
|
1747
2248
|
.join('')}
|
|
1748
2249
|
</ul>
|
|
1749
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>
|
|
1750
2261
|
`
|
|
1751
2262
|
}
|
|
1752
2263
|
|
|
@@ -1779,6 +2290,62 @@ function computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff) {
|
|
|
1779
2290
|
return byAuthorDay
|
|
1780
2291
|
}
|
|
1781
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
|
+
|
|
1782
2349
|
function renderWeeklyDurationRiskSummary(
|
|
1783
2350
|
commits,
|
|
1784
2351
|
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
@@ -1831,11 +2398,11 @@ function renderWeeklyDurationRiskSummary(
|
|
|
1831
2398
|
`
|
|
1832
2399
|
}
|
|
1833
2400
|
|
|
1834
|
-
function
|
|
2401
|
+
function renderMonthlyDurationRankSummary(
|
|
1835
2402
|
commits,
|
|
1836
2403
|
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
1837
2404
|
) {
|
|
1838
|
-
const box = document.getElementById('
|
|
2405
|
+
const box = document.getElementById('monthlyDurationRankSummary')
|
|
1839
2406
|
if (!box) return
|
|
1840
2407
|
const now = new Date()
|
|
1841
2408
|
const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
@@ -1845,34 +2412,38 @@ function renderMonthlyDurationRiskSummary(
|
|
|
1845
2412
|
endHour,
|
|
1846
2413
|
cutoff
|
|
1847
2414
|
)
|
|
1848
|
-
const
|
|
2415
|
+
const ranks = []
|
|
1849
2416
|
byAuthorDay.forEach((dayMap, author) => {
|
|
1850
2417
|
let total = 0
|
|
1851
2418
|
dayMap.forEach((v, dayKey) => {
|
|
1852
2419
|
const m = dayKey.slice(0, 7)
|
|
1853
2420
|
if (m === curMonth) total += v
|
|
1854
2421
|
})
|
|
1855
|
-
if (total > 0)
|
|
2422
|
+
if (total > 0) ranks.push({ author, total })
|
|
1856
2423
|
})
|
|
1857
|
-
|
|
1858
|
-
|
|
2424
|
+
ranks.sort(
|
|
2425
|
+
(a, b) =>
|
|
2426
|
+
b.total - a.total || String(a.author).localeCompare(String(b.author))
|
|
2427
|
+
)
|
|
2428
|
+
|
|
1859
2429
|
const lines = []
|
|
1860
|
-
lines.push('
|
|
1861
|
-
if (
|
|
1862
|
-
lines.push('
|
|
2430
|
+
lines.push('【本月加班时长排名】')
|
|
2431
|
+
if (ranks.length === 0) {
|
|
2432
|
+
lines.push('本月暂无加班时长。')
|
|
1863
2433
|
} else {
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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 ? '(状元・夜魔侠)' : ''
|
|
1868
2439
|
lines.push(
|
|
1869
|
-
`${author}
|
|
2440
|
+
`${rank}. ${medal}${r.author} — ${r.total.toFixed(2)} 小时${title}`
|
|
1870
2441
|
)
|
|
1871
2442
|
})
|
|
1872
2443
|
}
|
|
1873
2444
|
box.innerHTML = `
|
|
1874
2445
|
<div class="risk-summary">
|
|
1875
|
-
<div class="risk-title"
|
|
2446
|
+
<div class="risk-title">【本月加班时长排名】</div>
|
|
1876
2447
|
<ul>
|
|
1877
2448
|
${lines
|
|
1878
2449
|
.slice(1)
|
|
@@ -1883,19 +2454,14 @@ function renderMonthlyDurationRiskSummary(
|
|
|
1883
2454
|
`
|
|
1884
2455
|
}
|
|
1885
2456
|
|
|
1886
|
-
function
|
|
2457
|
+
function renderMonthlyDurationRiskSummary(
|
|
1887
2458
|
commits,
|
|
1888
2459
|
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
1889
2460
|
) {
|
|
1890
|
-
const box = document.getElementById('
|
|
2461
|
+
const box = document.getElementById('monthlyDurationRiskSummary')
|
|
1891
2462
|
if (!box) return
|
|
1892
2463
|
const now = new Date()
|
|
1893
|
-
const
|
|
1894
|
-
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
|
1895
|
-
)
|
|
1896
|
-
utcToday.setUTCDate(utcToday.getUTCDate() - 29)
|
|
1897
|
-
const startKey = utcToday.toISOString().slice(0, 10)
|
|
1898
|
-
|
|
2464
|
+
const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
1899
2465
|
const byAuthorDay = computeAuthorDailyMaxOvertime(
|
|
1900
2466
|
commits,
|
|
1901
2467
|
startHour,
|
|
@@ -1906,23 +2472,80 @@ function renderRolling30DurationRiskSummary(
|
|
|
1906
2472
|
byAuthorDay.forEach((dayMap, author) => {
|
|
1907
2473
|
let total = 0
|
|
1908
2474
|
dayMap.forEach((v, dayKey) => {
|
|
1909
|
-
|
|
2475
|
+
const m = dayKey.slice(0, 7)
|
|
2476
|
+
if (m === curMonth) total += v
|
|
1910
2477
|
})
|
|
1911
2478
|
if (total > 0) sums.push({ author, total })
|
|
1912
2479
|
})
|
|
1913
2480
|
sums.sort((a, b) => b.total - a.total)
|
|
1914
2481
|
const top = sums.slice(0, 6)
|
|
1915
2482
|
const lines = []
|
|
1916
|
-
lines.push('
|
|
2483
|
+
lines.push('【本月加班时长风险】')
|
|
1917
2484
|
if (top.length === 0) {
|
|
1918
|
-
lines.push('
|
|
2485
|
+
lines.push('本月暂无加班时长风险。')
|
|
1919
2486
|
} else {
|
|
1920
2487
|
top.forEach(({ author, total }) => {
|
|
1921
2488
|
let level = '轻度'
|
|
1922
2489
|
if (total >= 20) level = '严重'
|
|
1923
2490
|
else if (total >= 10) level = '中度'
|
|
1924
2491
|
lines.push(
|
|
1925
|
-
`${author}
|
|
2492
|
+
`${author} 本月累计加班 ${total.toFixed(2)} 小时(${level})。`
|
|
2493
|
+
)
|
|
2494
|
+
})
|
|
2495
|
+
}
|
|
2496
|
+
box.innerHTML = `
|
|
2497
|
+
<div class="risk-summary">
|
|
2498
|
+
<div class="risk-title">【本月加班时长风险】</div>
|
|
2499
|
+
<ul>
|
|
2500
|
+
${lines
|
|
2501
|
+
.slice(1)
|
|
2502
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2503
|
+
.join('')}
|
|
2504
|
+
</ul>
|
|
2505
|
+
</div>
|
|
2506
|
+
`
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
function renderRolling30DurationRiskSummary(
|
|
2510
|
+
commits,
|
|
2511
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
2512
|
+
) {
|
|
2513
|
+
const box = document.getElementById('rolling30DurationRiskSummary')
|
|
2514
|
+
if (!box) return
|
|
2515
|
+
const now = new Date()
|
|
2516
|
+
const utcToday = new Date(
|
|
2517
|
+
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
|
2518
|
+
)
|
|
2519
|
+
utcToday.setUTCDate(utcToday.getUTCDate() - 29)
|
|
2520
|
+
const startKey = utcToday.toISOString().slice(0, 10)
|
|
2521
|
+
|
|
2522
|
+
const byAuthorDay = computeAuthorDailyMaxOvertime(
|
|
2523
|
+
commits,
|
|
2524
|
+
startHour,
|
|
2525
|
+
endHour,
|
|
2526
|
+
cutoff
|
|
2527
|
+
)
|
|
2528
|
+
const sums = []
|
|
2529
|
+
byAuthorDay.forEach((dayMap, author) => {
|
|
2530
|
+
let total = 0
|
|
2531
|
+
dayMap.forEach((v, dayKey) => {
|
|
2532
|
+
if (dayKey >= startKey) total += v
|
|
2533
|
+
})
|
|
2534
|
+
if (total > 0) sums.push({ author, total })
|
|
2535
|
+
})
|
|
2536
|
+
sums.sort((a, b) => b.total - a.total)
|
|
2537
|
+
const top = sums.slice(0, 6)
|
|
2538
|
+
const lines = []
|
|
2539
|
+
lines.push('【最近30天加班时长风险】')
|
|
2540
|
+
if (top.length === 0) {
|
|
2541
|
+
lines.push('最近30天暂无加班时长风险。')
|
|
2542
|
+
} else {
|
|
2543
|
+
top.forEach(({ author, total }) => {
|
|
2544
|
+
let level = '轻度'
|
|
2545
|
+
if (total >= 20) level = '严重'
|
|
2546
|
+
else if (total >= 10) level = '中度'
|
|
2547
|
+
lines.push(
|
|
2548
|
+
`${author} 最近30天累计加班 ${total.toFixed(2)} 小时(${level})。`
|
|
1926
2549
|
)
|
|
1927
2550
|
})
|
|
1928
2551
|
}
|
|
@@ -2035,6 +2658,28 @@ function renderMonthlyRiskSummary(
|
|
|
2035
2658
|
}
|
|
2036
2659
|
}
|
|
2037
2660
|
|
|
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
|
+
|
|
2038
2683
|
box.innerHTML = `
|
|
2039
2684
|
<div class="risk-summary">
|
|
2040
2685
|
<div class="risk-title">【本月加班风险】</div>
|
|
@@ -2045,6 +2690,16 @@ function renderMonthlyRiskSummary(
|
|
|
2045
2690
|
.join('')}
|
|
2046
2691
|
</ul>
|
|
2047
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>`)
|
|
2700
|
+
.join('')}
|
|
2701
|
+
</ul>
|
|
2702
|
+
</div>
|
|
2048
2703
|
`
|
|
2049
2704
|
}
|
|
2050
2705
|
|
|
@@ -2096,6 +2751,7 @@ function drawAuthorLatestOvertimeTrends(commits, stats) {
|
|
|
2096
2751
|
const el = document.getElementById('chartAuthorLatestOvertime')
|
|
2097
2752
|
if (!el) return null
|
|
2098
2753
|
const chart = echarts.init(el)
|
|
2754
|
+
chartInstances.push(chart)
|
|
2099
2755
|
|
|
2100
2756
|
const startHour =
|
|
2101
2757
|
typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
@@ -2143,6 +2799,11 @@ function drawAuthorLatestOvertimeTrends(commits, stats) {
|
|
|
2143
2799
|
|
|
2144
2800
|
const lines = params
|
|
2145
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
|
+
)
|
|
2146
2807
|
.map(
|
|
2147
2808
|
(item) => `${item.marker}${item.seriesName}: ${item.data} 小时`
|
|
2148
2809
|
)
|
|
@@ -2177,144 +2838,1936 @@ function drawAuthorLatestOvertimeTrends(commits, stats) {
|
|
|
2177
2838
|
})
|
|
2178
2839
|
})
|
|
2179
2840
|
|
|
2180
|
-
|
|
2181
|
-
|
|
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
|
+
})
|
|
2182
2901
|
|
|
2183
2902
|
return chart
|
|
2184
2903
|
}
|
|
2185
2904
|
|
|
2186
|
-
//
|
|
2187
|
-
function
|
|
2905
|
+
// ====== 开发者 累计加班时长(按日/周/月/年累计日峰值加班时长求和) ======
|
|
2906
|
+
function buildAuthorTotalOvertimeDataset(
|
|
2188
2907
|
commits,
|
|
2189
|
-
|
|
2908
|
+
type,
|
|
2909
|
+
startHour,
|
|
2910
|
+
endHour,
|
|
2911
|
+
cutoff
|
|
2190
2912
|
) {
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
const
|
|
2199
|
-
|
|
2200
|
-
// 统计每周每人最大超时
|
|
2201
|
-
const weekMax = new Map() // week -> Map(author -> {max, date})
|
|
2202
|
-
commits.forEach((c) => {
|
|
2203
|
-
const d = new Date(c.date)
|
|
2204
|
-
if (Number.isNaN(d.valueOf())) return
|
|
2205
|
-
const h = d.getHours()
|
|
2206
|
-
let overtime = null
|
|
2207
|
-
if (h >= endHour && h < 24) overtime = h - endHour
|
|
2208
|
-
else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
|
|
2209
|
-
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()
|
|
2210
2922
|
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
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)
|
|
2219
2938
|
}
|
|
2220
2939
|
})
|
|
2221
2940
|
|
|
2222
|
-
const
|
|
2223
|
-
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
|
+
}))
|
|
2224
2951
|
|
|
2225
|
-
//
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
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 }
|
|
2233
2963
|
})
|
|
2234
2964
|
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
prevMap.forEach((v) => {
|
|
2238
|
-
if (v.max > prevMax) prevMax = v.max
|
|
2239
|
-
})
|
|
2965
|
+
return { authors, allPeriods, series, totals }
|
|
2966
|
+
}
|
|
2240
2967
|
|
|
2241
|
-
|
|
2242
|
-
|
|
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)
|
|
2243
2973
|
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
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
|
|
2257
2991
|
)
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
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
|
+
}
|
|
2262
2998
|
}
|
|
2263
|
-
}
|
|
2264
|
-
|
|
2265
|
-
box.innerHTML = `
|
|
2266
|
-
<div class="risk-summary">
|
|
2267
|
-
<div class="risk-title">【本周最晚加班风险】</div>
|
|
2268
|
-
<ul>
|
|
2269
|
-
${lines
|
|
2270
|
-
.slice(1)
|
|
2271
|
-
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2272
|
-
.join('')}
|
|
2273
|
-
</ul>
|
|
2274
|
-
</div>
|
|
2275
|
-
`
|
|
2276
|
-
}
|
|
2277
2999
|
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
) {
|
|
2282
|
-
|
|
2283
|
-
|
|
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
|
+
})
|
|
2284
3031
|
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
3032
|
+
// 同步更新下面的排名列表
|
|
3033
|
+
try {
|
|
3034
|
+
renderAuthorTotalOvertimeRankFromDs(ds, 0)
|
|
3035
|
+
renderAuthorTotalOvertimeRank(ds, 0)
|
|
3036
|
+
} catch (e) {
|
|
3037
|
+
console.warn('更新累计加班排名失败', e)
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
2290
3040
|
|
|
2291
|
-
|
|
2292
|
-
commits.forEach((c) => {
|
|
2293
|
-
const d = new Date(c.date)
|
|
2294
|
-
if (Number.isNaN(d.valueOf())) return
|
|
2295
|
-
const h = d.getHours()
|
|
2296
|
-
let overtime = null
|
|
2297
|
-
if (h >= endHour && h < 24) overtime = h - endHour
|
|
2298
|
-
else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
|
|
2299
|
-
if (overtime == null) return
|
|
3041
|
+
render('daily')
|
|
2300
3042
|
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
}
|
|
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
|
+
})
|
|
2309
3050
|
})
|
|
2310
3051
|
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
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
|
+
})
|
|
3089
|
+
|
|
3090
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
3091
|
+
|
|
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)
|
|
3113
|
+
}
|
|
3114
|
+
})
|
|
3115
|
+
|
|
3116
|
+
return chart
|
|
3117
|
+
}
|
|
3118
|
+
|
|
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)) }
|
|
3138
|
+
})
|
|
3139
|
+
|
|
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
|
+
}
|
|
3160
|
+
|
|
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)
|
|
3175
|
+
|
|
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
|
+
})
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
// 5. 调用现有绘图方法
|
|
3184
|
+
return drawPieWithTotal({
|
|
3185
|
+
el: 'authorTotalOvertimeRankSummary',
|
|
3186
|
+
title: '加班总时长排名分布',
|
|
3187
|
+
unit: '小时',
|
|
3188
|
+
totalLabel: '总时长',
|
|
3189
|
+
data: chartData,
|
|
3190
|
+
colors: generateColors(chartData.length)
|
|
3191
|
+
})
|
|
3192
|
+
}
|
|
3193
|
+
|
|
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) {
|
|
2318
4771
|
top = v
|
|
2319
4772
|
topAuthor = k
|
|
2320
4773
|
}
|
|
@@ -2322,34 +4775,174 @@ function renderLatestMonthlyRiskSummary(
|
|
|
2322
4775
|
|
|
2323
4776
|
let prevMax = -1
|
|
2324
4777
|
prevMap.forEach((v) => {
|
|
2325
|
-
if (v.
|
|
4778
|
+
if (v.val > prevMax) prevMax = v.val
|
|
2326
4779
|
})
|
|
2327
4780
|
|
|
2328
4781
|
const lines = []
|
|
2329
|
-
lines.push('
|
|
4782
|
+
lines.push('【本周午休最晚提交风险】')
|
|
2330
4783
|
|
|
2331
|
-
if (top.
|
|
2332
|
-
lines.push('
|
|
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('本月午休期间暂无提交记录。')
|
|
2333
4929
|
} else {
|
|
2334
4930
|
let trend = '暂无上月对比'
|
|
2335
4931
|
if (prevMax >= 0) {
|
|
2336
|
-
if (top.
|
|
2337
|
-
else if (top.
|
|
4932
|
+
if (top.val > prevMax) trend = '较上月更晚'
|
|
4933
|
+
else if (top.val < prevMax) trend = '较上月提前'
|
|
2338
4934
|
else trend = '与上月持平'
|
|
2339
4935
|
}
|
|
2340
4936
|
lines.push(
|
|
2341
|
-
`${topAuthor}
|
|
4937
|
+
`${topAuthor} 本月午休最晚提交:${top.time}(${top.date}),${trend}。)`
|
|
2342
4938
|
)
|
|
2343
|
-
if (top.
|
|
2344
|
-
lines.push('
|
|
2345
|
-
} else if (top.max >= 1) {
|
|
2346
|
-
lines.push('已超过 1 小时,注意控制夜间工作时长。')
|
|
2347
|
-
}
|
|
4939
|
+
if (top.val >= lunchEnd - 0.5)
|
|
4940
|
+
lines.push('存在午间延迟提交风险,请关注短时间内频繁占用午休。')
|
|
2348
4941
|
}
|
|
2349
4942
|
|
|
2350
4943
|
box.innerHTML = `
|
|
2351
4944
|
<div class="risk-summary">
|
|
2352
|
-
<div class="risk-title"
|
|
4945
|
+
<div class="risk-title">【本月午休最晚提交风险】</div>
|
|
2353
4946
|
<ul>
|
|
2354
4947
|
${lines
|
|
2355
4948
|
.slice(1)
|
|
@@ -2360,24 +4953,380 @@ function renderLatestMonthlyRiskSummary(
|
|
|
2360
4953
|
`
|
|
2361
4954
|
}
|
|
2362
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
|
+
|
|
2363
5269
|
async function main() {
|
|
2364
|
-
const {
|
|
2365
|
-
commits,
|
|
2366
|
-
stats,
|
|
2367
|
-
weekly,
|
|
2368
|
-
monthly,
|
|
2369
|
-
latestByDay,
|
|
2370
|
-
config,
|
|
2371
|
-
authorChanges
|
|
2372
|
-
} = await loadData()
|
|
5270
|
+
const { commits, config, authorChanges, options } = await loadData()
|
|
2373
5271
|
commitsAll = commits
|
|
2374
5272
|
filtered = commitsAll.slice()
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
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
|
+
|
|
2381
5330
|
initTableControls()
|
|
2382
5331
|
updatePager()
|
|
2383
5332
|
renderCommitsTablePage()
|
|
@@ -2414,10 +5363,34 @@ async function main() {
|
|
|
2414
5363
|
console.log('最累的一天:', daily.analysis.mostTiredDay)
|
|
2415
5364
|
|
|
2416
5365
|
drawChangeTrends(authorChanges)
|
|
5366
|
+
// 开发者 累计提交次数(按日/周/月/年)
|
|
5367
|
+
drawAuthorTotalCommitsTrends(commits)
|
|
5368
|
+
// 开发者 累计提交Changed(按日/周/月/年)
|
|
5369
|
+
drawAuthorTotalCommitsChangedTrends(commits)
|
|
2417
5370
|
drawAuthorOvertimeTrends(commits, stats)
|
|
2418
5371
|
drawAuthorLatestOvertimeTrends(commits, stats)
|
|
5372
|
+
drawAuthorLunchTrends(commits, stats)
|
|
5373
|
+
// 新增:开发者累计午休时长(按日/周/月/年)
|
|
5374
|
+
drawAuthorTotalLunchTimeTrends(commits, stats)
|
|
5375
|
+
// 新增:开发者累计加班时长(按日/周/月/年)
|
|
5376
|
+
drawAuthorTotalOvertimeTrends(commits, stats)
|
|
2419
5377
|
computeAndRenderLatestOvertime(latestByDay)
|
|
2420
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)
|
|
2421
5394
|
}
|
|
2422
5395
|
|
|
2423
5396
|
// 抽屉关闭交互(按钮 + 点击遮罩)
|