wukong-gitlog-cli 1.0.39 → 1.0.41
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 +97 -0
- package/README.md +97 -172
- package/README.zh-CN.md +88 -137
- package/bin/wukong-gitlog-cli +0 -0
- package/doc//347/233/256/345/275/225/347/273/223/346/236/204.md +2871 -0
- package/package.json +32 -29
- package/rc/.wukonggitlogrc +53 -0
- package/scripts/compareHourlyCounts.mjs +42 -0
- package/scripts/compareLatest.mjs +106 -0
- package/src/app/analyzeAction.mjs +120 -0
- package/src/app/exportAction.mjs +215 -0
- package/src/app/exportActionProgress.mjs +37 -0
- package/src/app/helpers.mjs +292 -0
- package/src/app/initAction.mjs +110 -0
- package/src/app/initActionWithTemp.mjs +192 -0
- package/src/app/journalAction.mjs +117 -0
- package/src/app/overtimeAction.mjs +100 -0
- package/src/app/runProfileEnd.mjs +0 -0
- package/src/app/serveAction.mjs +73 -0
- package/src/app/versionAction.mjs +7 -0
- package/src/cli/defineOptions.mjs +209 -0
- package/src/cli/index.mjs +0 -0
- package/src/cli/parseOptions.mjs +126 -8
- package/src/constants/index.mjs +16 -2
- package/src/domain/author/analyze.mjs +6 -0
- package/src/domain/author/map.mjs +0 -0
- package/src/domain/export/exportAuthor.mjs +28 -0
- package/src/domain/export/exportAuthorChanges.mjs +27 -0
- package/src/domain/export/exportAuthorChangesJson.mjs +31 -0
- package/src/domain/export/exportByMonth.mjs +157 -0
- package/src/domain/export/exportByWeek.mjs +121 -0
- package/src/domain/export/exportCommits.mjs +26 -0
- package/src/domain/export/exportCommitsExcel.mjs +45 -0
- package/src/domain/export/exportCommitsJson.mjs +31 -0
- package/src/domain/export/index.mjs +91 -0
- package/src/domain/git/ensureGitAvailable.mjs +66 -0
- package/src/domain/git/ensureGitRepo.mjs +41 -0
- package/src/domain/git/getGitFeatures.mjs +59 -0
- package/src/domain/git/getGitLogs.mjs +326 -0
- package/src/domain/git/getGitUser.mjs +44 -0
- package/src/domain/git/getRepoRoot.mjs +32 -0
- package/src/domain/git/gitCapability.mjs +119 -0
- package/src/domain/git/index.mjs +96 -0
- package/src/domain/git/resolveGerrit.mjs +102 -0
- package/src/domain/overtime/analyze.mjs +48 -0
- package/src/domain/overtime/index.mjs +3 -0
- package/src/domain/overtime/perPeriod.mjs +15 -0
- package/src/domain/overtime/render.mjs +15 -0
- package/src/i18n/index.mjs +38 -0
- package/src/i18n/resources.mjs +252 -0
- package/src/index.mjs +132 -649
- package/src/infra/cache.mjs +0 -0
- package/src/infra/configStore.mjs +128 -0
- package/src/infra/fs.mjs +0 -0
- package/src/infra/path.mjs +0 -0
- package/src/output/csv/overtime.mjs +12 -0
- package/src/output/csv.mjs +0 -0
- package/src/output/data/readData.mjs +54 -0
- package/src/output/data/writeData.mjs +145 -0
- package/src/output/excel/commits.mjs +9 -0
- package/src/output/excel/outputExcelDayReport.mjs +92 -0
- package/src/output/excel/perPeriod.mjs +24 -0
- package/src/{excel.mjs → output/excel.mjs} +3 -2
- package/src/output/index.mjs +79 -0
- package/src/output/json/overtime.mjs +9 -0
- package/src/output/tab/overtime.mjs +12 -0
- package/src/output/tab.mjs +0 -0
- package/src/output/text/commits.mjs +9 -0
- package/src/output/text/index.mjs +3 -0
- package/src/output/text/outputTxtDayReport.mjs +74 -0
- package/src/output/text/overtime.mjs +18 -0
- package/src/output/utils/getEsmJs.mjs +10 -0
- package/src/output/utils/index.mjs +14 -0
- package/src/output/utils/outputPath.mjs +19 -0
- package/src/output/utils/writeFile.mjs +10 -0
- package/src/serve/index.mjs +0 -0
- package/src/{server.mjs → serve/startServer.mjs} +21 -3
- package/src/serve/writeData.mjs +0 -0
- package/src/utils/authorNormalizer.mjs +28 -2
- package/src/utils/buildAuthorChangeStats.mjs +44 -0
- package/src/utils/deepMerge.mjs +13 -0
- package/src/utils/getPackage.mjs +11 -0
- package/src/utils/getProfileDirFile.mjs +12 -0
- package/src/utils/{file.mjs → groupRecords.mjs} +8 -9
- package/src/utils/index.mjs +5 -2
- package/src/utils/logger.mjs +28 -17
- package/src/utils/profiler.mjs +0 -101
- package/src/utils/resolve.mjs +11 -0
- package/src/utils/showVersionInfo.mjs +6 -2
- package/src/utils/time.mjs +0 -0
- package/src/utils/wait.mjs +2 -0
- package/web/app.js +3197 -257
- package/web/index.html +171 -22
- package/web/revoke/alpha1/app.js +4324 -0
- package/web/revoke/alpha1/index.html +266 -0
- package/web/revoke/app.before.js +3139 -0
- package/web/revoke/index-before.html +181 -0
- package/web/static/style.css +116 -9
- package/src/git.mjs +0 -256
- package/src/handlers/handleServe.mjs +0 -203
- package/src/lib/configStore.mjs +0 -11
- package/src/lib/memoize.mjs +0 -14
- package/src/utils/analyzeOvertimeCached.mjs +0 -7
- package/src/utils/checkUpdate.mjs +0 -130
- package/src/utils/exitWithTime.mjs +0 -17
- package/src/utils/handleSuccess.mjs +0 -9
- package/src/utils/logDev.mjs +0 -19
- package/src/utils/output.mjs +0 -26
- package/src/utils/profiler/diff.mjs +0 -26
- package/src/utils/profiler/format.mjs +0 -11
- package/src/utils/profiler/index.mjs +0 -144
- package/src/utils/profiler/trace.mjs +0 -26
- package/src/utils/time/scopeTimer.mjs +0 -37
- package/src/utils/time/timer.mjs +0 -33
- package/src/utils/time/withTimer.mjs +0 -11
- package/src/utils/timer.mjs +0 -35
- /package/src/{overtime → domain/overtime}/createOvertimeStats.mjs +0 -0
- /package/src/{overtime → domain/overtime}/overtime.mjs +0 -0
- /package/src/{json.mjs → output/json.mjs} +0 -0
- /package/src/{renderAuthorMapText.mjs → output/renderAuthorMapText.mjs} +0 -0
- /package/src/{stats-text.mjs → output/stats-text.mjs} +0 -0
- /package/src/{stats.mjs → output/stats.mjs} +0 -0
- /package/src/{text.mjs → output/text.mjs} +0 -0
|
@@ -0,0 +1,3139 @@
|
|
|
1
|
+
/* eslint-disable import/no-absolute-path */
|
|
2
|
+
/* eslint-disable no-use-before-define */
|
|
3
|
+
/* global echarts */
|
|
4
|
+
const formatDate = (d) => new Date(d).toLocaleString()
|
|
5
|
+
|
|
6
|
+
// 综合判断函数,考虑多种情况
|
|
7
|
+
function isEmptyObject(obj) {
|
|
8
|
+
// 1. 检查是否为对象
|
|
9
|
+
if (obj === null || typeof obj !== 'object') {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 2. 检查是否是空对象
|
|
14
|
+
return Object.keys(obj).length === 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 根据对象内容隐藏对应图表卡片
|
|
18
|
+
function hideElementByObj({ el, objectName }) {
|
|
19
|
+
const isEmpty = isEmptyObject(objectName)
|
|
20
|
+
if (isEmpty) {
|
|
21
|
+
const chartCard = el?.closest('.chart-card')
|
|
22
|
+
chartCard.style.display = 'none'
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
return isEmpty
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function filterByDate(commits) {
|
|
29
|
+
const start = document.getElementById('startDate')?.value
|
|
30
|
+
const end = document.getElementById('endDate')?.value
|
|
31
|
+
|
|
32
|
+
if (!start && !end) return commits
|
|
33
|
+
|
|
34
|
+
const startTime = start ? new Date(`${start}T00:00:00`).getTime() : -Infinity
|
|
35
|
+
|
|
36
|
+
const endTime = end ? new Date(`${end}T23:59:59`).getTime() : Infinity
|
|
37
|
+
|
|
38
|
+
return commits.filter((c) => {
|
|
39
|
+
const t = new Date(c.date).getTime()
|
|
40
|
+
return t >= startTime && t <= endTime
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ISO 周 key:YYYY-Www
|
|
45
|
+
function getIsoWeekKey(dStr) {
|
|
46
|
+
const d = new Date(dStr)
|
|
47
|
+
if (Number.isNaN(d.valueOf())) return null
|
|
48
|
+
const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
|
|
49
|
+
const dayNum = target.getUTCDay() || 7 // Sunday=0
|
|
50
|
+
target.setUTCDate(target.getUTCDate() + 4 - dayNum)
|
|
51
|
+
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
|
|
52
|
+
const weekNo = Math.ceil(((target - yearStart) / 86400000 + 1) / 7)
|
|
53
|
+
const year = target.getUTCFullYear()
|
|
54
|
+
return `${year}-W${String(weekNo).padStart(2, '0')}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatDateYMD(d) {
|
|
58
|
+
const yyyy = d.getFullYear()
|
|
59
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
|
60
|
+
const dd = String(d.getDate()).padStart(2, '0')
|
|
61
|
+
return `${yyyy}-${mm}-${dd}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getISOWeekRange(isoYear, isoWeek) {
|
|
65
|
+
// 找到 ISO 年的第一个周一
|
|
66
|
+
// ISO 年的第 1 周包含 1 月 4 日
|
|
67
|
+
const simple = new Date(isoYear, 0, 4)
|
|
68
|
+
const dayOfWeek = simple.getDay() || 7 // Sunday=7
|
|
69
|
+
const firstMonday = new Date(simple)
|
|
70
|
+
firstMonday.setDate(simple.getDate() - dayOfWeek + 1)
|
|
71
|
+
|
|
72
|
+
// 计算目标周的周一
|
|
73
|
+
const monday = new Date(firstMonday)
|
|
74
|
+
monday.setDate(firstMonday.getDate() + (isoWeek - 1) * 7)
|
|
75
|
+
|
|
76
|
+
const sunday = new Date(monday)
|
|
77
|
+
sunday.setDate(monday.getDate() + 6)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
start: formatDateYMD(monday),
|
|
81
|
+
end: formatDateYMD(sunday)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function loadData() {
|
|
86
|
+
// 定义加载函数,包装 import 以便添加错误处理
|
|
87
|
+
const safeImport = async (path, defaultValue) => {
|
|
88
|
+
try {
|
|
89
|
+
const module = await import(path)
|
|
90
|
+
return module.default || defaultValue
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.warn(`文件加载失败: ${path}`, e)
|
|
93
|
+
return defaultValue
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 并行加载所有静态模块
|
|
98
|
+
const [commits, stats, weekly, monthly, latestByDay, config, authorChanges] =
|
|
99
|
+
await Promise.all([
|
|
100
|
+
safeImport('/data/commits.mjs', []),
|
|
101
|
+
safeImport('/data/overtime.mjs', {}),
|
|
102
|
+
safeImport('/data/overtime.week.mjs', []),
|
|
103
|
+
safeImport('/data/overtime.month.mjs', []),
|
|
104
|
+
safeImport('/data/overtime.latest.commit.day.mjs', []),
|
|
105
|
+
safeImport('/data/config.mjs', {}),
|
|
106
|
+
safeImport('/data/author.changes.mjs', {})
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
return { commits, stats, weekly, monthly, latestByDay, config, authorChanges }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let commitsAll = []
|
|
113
|
+
let filtered = []
|
|
114
|
+
let page = 1
|
|
115
|
+
let pageSize = 10
|
|
116
|
+
|
|
117
|
+
function renderCommitsTablePage() {
|
|
118
|
+
const tbody = document.querySelector('#commitsTable tbody')
|
|
119
|
+
tbody.innerHTML = ''
|
|
120
|
+
const start = (page - 1) * pageSize
|
|
121
|
+
const end = start + pageSize
|
|
122
|
+
filtered.slice(start, end).forEach((c) => {
|
|
123
|
+
const tr = document.createElement('tr')
|
|
124
|
+
tr.innerHTML = `<td>${c.hash.slice(0, 8)}</td><td>${c.author}</td><td>${c.email}</td><td>${formatDate(c.date)}</td><td>${c.message}</td><td>${c.changed}</td>`
|
|
125
|
+
tbody.appendChild(tr)
|
|
126
|
+
})
|
|
127
|
+
document.getElementById('commitsTotal').textContent =
|
|
128
|
+
`共${filtered.length}条记录`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function updatePager() {
|
|
132
|
+
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
|
133
|
+
if (page > totalPages) page = totalPages
|
|
134
|
+
const pageInfo = document.getElementById('pageInfo')
|
|
135
|
+
pageInfo.textContent = `${page} / ${totalPages}`
|
|
136
|
+
document.getElementById('prevPage').disabled = page <= 1
|
|
137
|
+
document.getElementById('nextPage').disabled = page >= totalPages
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function applySearch() {
|
|
141
|
+
const q = document.getElementById('searchInput').value.trim().toLowerCase()
|
|
142
|
+
|
|
143
|
+
// ① 先做日期过滤
|
|
144
|
+
const base = filterByDate(commitsAll)
|
|
145
|
+
|
|
146
|
+
if (!q) {
|
|
147
|
+
filtered = base.slice()
|
|
148
|
+
} else {
|
|
149
|
+
filtered = base.filter((c) => {
|
|
150
|
+
const h = c.hash.toLowerCase()
|
|
151
|
+
const a = String(c.author || '').toLowerCase()
|
|
152
|
+
const e = String(c.email || '').toLowerCase()
|
|
153
|
+
const m = String(c.message || '').toLowerCase()
|
|
154
|
+
const d = formatDate(c.date).toLowerCase()
|
|
155
|
+
return (
|
|
156
|
+
h.includes(q) ||
|
|
157
|
+
a.includes(q) ||
|
|
158
|
+
e.includes(q) ||
|
|
159
|
+
m.includes(q) ||
|
|
160
|
+
d.includes(q)
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
page = 1
|
|
165
|
+
updatePager()
|
|
166
|
+
renderCommitsTablePage()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function initTableControls() {
|
|
170
|
+
document.getElementById('searchInput').addEventListener('input', applySearch)
|
|
171
|
+
document.getElementById('startDate')?.addEventListener('change', applySearch)
|
|
172
|
+
document.getElementById('endDate')?.addEventListener('change', applySearch)
|
|
173
|
+
document.getElementById('clearDate')?.addEventListener('click', () => {
|
|
174
|
+
document.getElementById('startDate').value = ''
|
|
175
|
+
document.getElementById('endDate').value = ''
|
|
176
|
+
applySearch()
|
|
177
|
+
})
|
|
178
|
+
document.getElementById('pageSize').addEventListener('change', (e) => {
|
|
179
|
+
pageSize = parseInt(e.target.value, 10) || 10
|
|
180
|
+
page = 1
|
|
181
|
+
updatePager()
|
|
182
|
+
renderCommitsTablePage()
|
|
183
|
+
})
|
|
184
|
+
document.getElementById('prevPage').addEventListener('click', () => {
|
|
185
|
+
if (page > 1) {
|
|
186
|
+
page -= 1
|
|
187
|
+
updatePager()
|
|
188
|
+
renderCommitsTablePage()
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
document.getElementById('nextPage').addEventListener('click', () => {
|
|
192
|
+
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
|
193
|
+
if (page < totalPages) {
|
|
194
|
+
page += 1
|
|
195
|
+
updatePager()
|
|
196
|
+
renderCommitsTablePage()
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function drawHourlyOvertime(stats, onHourClick) {
|
|
202
|
+
const el = document.getElementById('hourlyOvertimeChart')
|
|
203
|
+
const isEmpty = hideElementByObj({ el, objectName: stats })
|
|
204
|
+
if (isEmpty) {
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
const chart = echarts.init(el)
|
|
208
|
+
|
|
209
|
+
const commits = stats.hourlyOvertimeCommits || []
|
|
210
|
+
const percent = stats.hourlyOvertimePercent || []
|
|
211
|
+
const labels = Array.from({ length: 24 }, (_, i) =>
|
|
212
|
+
String(i).padStart(2, '0')
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
// 颜色逻辑(与 daily severity 风格一致)
|
|
216
|
+
function getColor(h) {
|
|
217
|
+
if (h >= 21) return '#d32f2f' // 深夜加班 红
|
|
218
|
+
if (h >= 19) return '#fb8c00' // 夜间加班 橙
|
|
219
|
+
if (h >= stats.lunchStart && h < stats.lunchEnd) return '#888888' // 午休灰
|
|
220
|
+
if (h >= stats.startHour && h < stats.endHour) return '#1976d2' // 工作时段 蓝
|
|
221
|
+
return '#b71c1c' // 凌晨 红
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const data = commits.map((v, h) => ({
|
|
225
|
+
value: v,
|
|
226
|
+
itemStyle: { color: getColor(h) }
|
|
227
|
+
}))
|
|
228
|
+
|
|
229
|
+
chart.setOption({
|
|
230
|
+
tooltip: {
|
|
231
|
+
trigger: 'axis',
|
|
232
|
+
formatter(params) {
|
|
233
|
+
const p = params[0]
|
|
234
|
+
const h = parseInt(p.axisValue, 10)
|
|
235
|
+
const count = p.value
|
|
236
|
+
const rate = (percent[h] * 100).toFixed(1)
|
|
237
|
+
return `
|
|
238
|
+
🕒 <b>${h}:00</b><br/>
|
|
239
|
+
提交次数:<b>${count}</b><br/>
|
|
240
|
+
占全天比例:<b>${rate}%</b>
|
|
241
|
+
`
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
xAxis: {
|
|
246
|
+
type: 'category',
|
|
247
|
+
data: labels,
|
|
248
|
+
axisLabel: { color: '#555' }
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
yAxis: {
|
|
252
|
+
type: 'value',
|
|
253
|
+
min: 0,
|
|
254
|
+
axisLabel: { color: '#555' }
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
grid: { left: 40, right: 30, top: 20, bottom: 40 },
|
|
258
|
+
|
|
259
|
+
series: [
|
|
260
|
+
{
|
|
261
|
+
type: 'bar',
|
|
262
|
+
name: 'Overtime commits',
|
|
263
|
+
data,
|
|
264
|
+
barWidth: 18,
|
|
265
|
+
|
|
266
|
+
markPoint: {
|
|
267
|
+
symbol: 'pin',
|
|
268
|
+
symbolSize: 45,
|
|
269
|
+
itemStyle: { color: '#d32f2f' },
|
|
270
|
+
data: [
|
|
271
|
+
{
|
|
272
|
+
name: '最晚提交',
|
|
273
|
+
coord: [
|
|
274
|
+
String(stats.latestCommitHour).padStart(2, '0'),
|
|
275
|
+
commits[stats.latestCommitHour]
|
|
276
|
+
]
|
|
277
|
+
}
|
|
278
|
+
]
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
markLine: {
|
|
282
|
+
symbol: 'none',
|
|
283
|
+
animation: true,
|
|
284
|
+
label: { color: '#888', formatter: '{b}' },
|
|
285
|
+
lineStyle: { type: 'dashed', color: '#aaa' },
|
|
286
|
+
data: [
|
|
287
|
+
{
|
|
288
|
+
name: '上班开始',
|
|
289
|
+
nameValue: String(stats.startHour).padStart(2, '0'),
|
|
290
|
+
xAxis: String(stats.startHour).padStart(2, '0')
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: '下班时间',
|
|
294
|
+
nameValue: String(stats.endHour).padStart(2, '0'),
|
|
295
|
+
xAxis: String(stats.endHour).padStart(2, '0')
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: '午休开始',
|
|
299
|
+
nameValue: String(stats.lunchStart).padStart(2, '0'),
|
|
300
|
+
xAxis: String(stats.lunchStart).padStart(2, '0')
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: '午休结束',
|
|
304
|
+
nameValue: String(stats.lunchEnd).padStart(2, '0'),
|
|
305
|
+
xAxis: String(stats.lunchEnd).padStart(2, '0')
|
|
306
|
+
}
|
|
307
|
+
]
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
]
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
// 点击事件(点击某小时 → 打开侧栏)
|
|
314
|
+
if (typeof onHourClick === 'function') {
|
|
315
|
+
chart.on('click', (p) => {
|
|
316
|
+
let hour = Number(p.name)
|
|
317
|
+
if (p.componentType === 'markLine') {
|
|
318
|
+
hour = Number(p.data.xAxis)
|
|
319
|
+
}
|
|
320
|
+
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
321
|
+
if (Number.isNaN(hour)) return
|
|
322
|
+
onHourClick(hour, commits[hour])
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return chart
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// showSideBarForHour 实现
|
|
330
|
+
function showSideBarForHour({ hour, commitsOrCount, titleDrawer }) {
|
|
331
|
+
// 支持传入 number(仅次数)或 array(详细 commit 列表)
|
|
332
|
+
// 统一复用通用详情侧栏 DOM
|
|
333
|
+
const sidebar = document.getElementById('dayDetailSidebar')
|
|
334
|
+
const backdrop = document.getElementById('sidebarBackdrop')
|
|
335
|
+
const titleEl = document.getElementById('sidebarTitle')
|
|
336
|
+
const contentEl = document.getElementById('sidebarContent')
|
|
337
|
+
const drawerTitleEl = document.getElementById('sidebarDrawerTitle')
|
|
338
|
+
|
|
339
|
+
// 兼容未传入侧栏 DOM 的情况(优雅降级)
|
|
340
|
+
if (!sidebar || !titleEl || !contentEl) {
|
|
341
|
+
console.warn(
|
|
342
|
+
'hourDetailSidebar DOM not found. Please add the HTML snippet.'
|
|
343
|
+
)
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
drawerTitleEl.innerHTML = titleDrawer || '🕒 小时详情'
|
|
348
|
+
titleEl.innerHTML = `🕒 ${String(hour).padStart(2, '0')}:00 - ${String(hour).padStart(2, '0')}:59`
|
|
349
|
+
|
|
350
|
+
// 如果只是 number,显示计数
|
|
351
|
+
if (typeof commitsOrCount === 'number') {
|
|
352
|
+
contentEl.innerHTML = `<div style="font-size:14px;">提交次数:<b>${commitsOrCount}</b></div>`
|
|
353
|
+
} else if (Array.isArray(commitsOrCount) && commitsOrCount.length === 0) {
|
|
354
|
+
contentEl.innerHTML = `<div style="font-size:14px;">当小时无提交记录</div>`
|
|
355
|
+
} else if (Array.isArray(commitsOrCount)) {
|
|
356
|
+
// commits 列表:展示作者/时间/消息(最多前 50 条,避免性能问题)
|
|
357
|
+
const commits = commitsOrCount.slice(0, 50)
|
|
358
|
+
contentEl.innerHTML = `<div class="sidebar-list">${commits
|
|
359
|
+
.map((c) => {
|
|
360
|
+
const author = c.author ?? c.name ?? 'unknown'
|
|
361
|
+
const time = c.date ?? c.time ?? ''
|
|
362
|
+
const msg = (c.message ?? c.msg ?? c.body ?? '').replace(/\n/g, ' ')
|
|
363
|
+
return `
|
|
364
|
+
<div class="sidebar-item">
|
|
365
|
+
<div class="sidebar-item-header">
|
|
366
|
+
<span class="author">👤 ${escapeHtml(author)}</span>
|
|
367
|
+
<span class="time">🕒 ${escapeHtml(time)}</span>
|
|
368
|
+
</div>
|
|
369
|
+
<div class="sidebar-item-message">${escapeHtml(msg)}</div>
|
|
370
|
+
</div>
|
|
371
|
+
`
|
|
372
|
+
})
|
|
373
|
+
.join('')}</div>`
|
|
374
|
+
|
|
375
|
+
if (commitsOrCount.length > 50) {
|
|
376
|
+
const more = commitsOrCount.length - 50
|
|
377
|
+
contentEl.innerHTML += `<div style="color:#888; padding:8px 0">另外 ${more} 条已省略</div>`
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
contentEl.innerHTML = `<div style="font-size:14px;">无可展示数据</div>`
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 打开侧栏 + 遮罩
|
|
384
|
+
sidebar.classList.add('show')
|
|
385
|
+
if (backdrop) backdrop.classList.add('show')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 简单的 HTML 转义,防止 XSS 与布局断裂
|
|
389
|
+
function escapeHtml(str = '') {
|
|
390
|
+
return String(str)
|
|
391
|
+
.replaceAll('&', '&')
|
|
392
|
+
.replaceAll('<', '<')
|
|
393
|
+
.replaceAll('>', '>')
|
|
394
|
+
.replaceAll('"', '"')
|
|
395
|
+
.replaceAll("'", ''')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function drawOutsideVsInside(stats) {
|
|
399
|
+
const el = document.getElementById('outsideVsInsideChart')
|
|
400
|
+
// eslint-disable-next-line no-undef
|
|
401
|
+
const chart = echarts.init(el)
|
|
402
|
+
const outside = stats.outsideWorkCount || 0
|
|
403
|
+
const total = stats.total || 0
|
|
404
|
+
const inside = Math.max(0, total - outside)
|
|
405
|
+
chart.setOption({
|
|
406
|
+
tooltip: {},
|
|
407
|
+
series: [
|
|
408
|
+
{
|
|
409
|
+
type: 'pie',
|
|
410
|
+
radius: '55%',
|
|
411
|
+
data: [
|
|
412
|
+
{ value: inside, name: '工作时间内' },
|
|
413
|
+
{ value: outside, name: '下班时间' }
|
|
414
|
+
]
|
|
415
|
+
}
|
|
416
|
+
]
|
|
417
|
+
})
|
|
418
|
+
return chart
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function drawDailyTrend(commits, onDayClick) {
|
|
422
|
+
if (!Array.isArray(commits) || commits.length === 0) return null
|
|
423
|
+
|
|
424
|
+
// 聚合每日提交数量
|
|
425
|
+
const map = new Map()
|
|
426
|
+
commits.forEach((c) => {
|
|
427
|
+
const d = new Date(c.date).toISOString().slice(0, 10)
|
|
428
|
+
map.set(d, (map.get(d) || 0) + 1)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
const labels = Array.from(map.keys()).sort()
|
|
432
|
+
const data = labels.map((l) => map.get(l))
|
|
433
|
+
|
|
434
|
+
const el = document.getElementById('dailyTrendChart')
|
|
435
|
+
const titleDrawer = el.getAttribute('data-title') || ''
|
|
436
|
+
|
|
437
|
+
// eslint-disable-next-line no-undef
|
|
438
|
+
const chart = echarts.init(el)
|
|
439
|
+
|
|
440
|
+
chart.setOption({
|
|
441
|
+
tooltip: {
|
|
442
|
+
trigger: 'axis',
|
|
443
|
+
formatter: (params) => {
|
|
444
|
+
const p = params?.[0]
|
|
445
|
+
if (!p) return ''
|
|
446
|
+
|
|
447
|
+
const date = p.axisValue
|
|
448
|
+
const count = p.data
|
|
449
|
+
|
|
450
|
+
// 分级说明
|
|
451
|
+
let level = '🟢 正常(≤5 次)'
|
|
452
|
+
if (count > 5 && count < 10) level = '🟠 较高频(6–10 次)'
|
|
453
|
+
if (count >= 10) level = '🔴 高频(≥10 次)'
|
|
454
|
+
|
|
455
|
+
return `
|
|
456
|
+
<div style="font-size:13px; line-height:1.5;">
|
|
457
|
+
<b>${date}</b><br/>
|
|
458
|
+
提交次数:<b>${count}</b><br/>
|
|
459
|
+
等级:${level}
|
|
460
|
+
</div>
|
|
461
|
+
`
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
xAxis: { type: 'category', data: labels },
|
|
466
|
+
|
|
467
|
+
yAxis: { type: 'value', min: 0 },
|
|
468
|
+
|
|
469
|
+
series: [
|
|
470
|
+
{
|
|
471
|
+
type: 'line',
|
|
472
|
+
name: '每日提交',
|
|
473
|
+
data,
|
|
474
|
+
|
|
475
|
+
smooth: true,
|
|
476
|
+
|
|
477
|
+
// ⭐ area 渐变背景
|
|
478
|
+
areaStyle: {
|
|
479
|
+
opacity: 0.2
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
// ⭐ 背景区间(低 / 中 / 高频)
|
|
483
|
+
markArea: {
|
|
484
|
+
data: [
|
|
485
|
+
[
|
|
486
|
+
{ yAxis: 0 },
|
|
487
|
+
{ yAxis: 5, itemStyle: { color: 'rgba(76, 175, 80, 0.12)' } } // 绿
|
|
488
|
+
],
|
|
489
|
+
[
|
|
490
|
+
{ yAxis: 5 },
|
|
491
|
+
{ yAxis: 10, itemStyle: { color: 'rgba(251, 140, 0, 0.12)' } } // 橙
|
|
492
|
+
],
|
|
493
|
+
[
|
|
494
|
+
{ yAxis: 10 },
|
|
495
|
+
{ yAxis: 50, itemStyle: { color: 'rgba(211, 47, 47, 0.12)' } } // 红
|
|
496
|
+
]
|
|
497
|
+
]
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
// ⭐ 阈值线
|
|
501
|
+
markLine: {
|
|
502
|
+
symbol: ['none', 'arrow'],
|
|
503
|
+
data: [
|
|
504
|
+
{
|
|
505
|
+
yAxis: 5,
|
|
506
|
+
lineStyle: { color: '#fb8c00', width: 2, type: 'dashed' },
|
|
507
|
+
label: { formatter: '5 次', color: '#fb8c00' }
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
yAxis: 10,
|
|
511
|
+
lineStyle: { color: '#d32f2f', width: 2, type: 'dashed' },
|
|
512
|
+
label: { formatter: '10 次', color: '#d32f2f' }
|
|
513
|
+
}
|
|
514
|
+
]
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
]
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
// 点击某一天,打开抽屉显示当日 commits
|
|
521
|
+
if (typeof onDayClick === 'function') {
|
|
522
|
+
chart.on('click', (params) => {
|
|
523
|
+
const idx = params.dataIndex
|
|
524
|
+
const date = labels[idx]
|
|
525
|
+
const count = data[idx]
|
|
526
|
+
const dayCommits = commits.filter(
|
|
527
|
+
(c) => new Date(c.date).toISOString().slice(0, 10) === date
|
|
528
|
+
)
|
|
529
|
+
// onDayClick(date, count, dayCommits)
|
|
530
|
+
onDayClick({
|
|
531
|
+
date,
|
|
532
|
+
count,
|
|
533
|
+
commits: dayCommits,
|
|
534
|
+
titleDrawer
|
|
535
|
+
})
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return chart
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function showSideBarForWeek({ period, weeklyItem, commits = [], titleDrawer }) {
|
|
543
|
+
// 统一复用通用详情侧栏 DOM
|
|
544
|
+
const sidebar = document.getElementById('dayDetailSidebar')
|
|
545
|
+
const backdrop = document.getElementById('sidebarBackdrop')
|
|
546
|
+
const titleEl = document.getElementById('sidebarTitle')
|
|
547
|
+
const contentEl = document.getElementById('sidebarContent')
|
|
548
|
+
const drawerTitleEl = document.getElementById('sidebarDrawerTitle')
|
|
549
|
+
|
|
550
|
+
titleEl.innerHTML = `📅 周期:<b>${period}</b>`
|
|
551
|
+
drawerTitleEl.innerHTML = titleDrawer || ''
|
|
552
|
+
|
|
553
|
+
let html = `
|
|
554
|
+
<div style="padding:6px 0;">
|
|
555
|
+
加班次数:<b>${weeklyItem.outsideWorkCount}</b><br/>
|
|
556
|
+
占比:<b>${(weeklyItem.outsideWorkRate * 100).toFixed(1)}%</b>
|
|
557
|
+
</div>
|
|
558
|
+
<hr/>
|
|
559
|
+
`
|
|
560
|
+
|
|
561
|
+
if (!commits.length) {
|
|
562
|
+
html += `<div style="padding:10px;color:#777;">该周无提交记录</div>`
|
|
563
|
+
} else {
|
|
564
|
+
html += `<div class="sidebar-list">${commits
|
|
565
|
+
.map((c) => {
|
|
566
|
+
const author = escapeHtml(c.author || 'unknown')
|
|
567
|
+
const time = escapeHtml(c.date || '')
|
|
568
|
+
const msg = escapeHtml((c.message || '').replace(/\n/g, ' '))
|
|
569
|
+
return `
|
|
570
|
+
<div class="sidebar-item">
|
|
571
|
+
<div class="sidebar-item-header">
|
|
572
|
+
<span class="author">👤 ${author}</span>
|
|
573
|
+
<span class="time">🕒 ${time}</span>
|
|
574
|
+
</div>
|
|
575
|
+
<div class="sidebar-item-message">${msg}</div>
|
|
576
|
+
</div>
|
|
577
|
+
`
|
|
578
|
+
})
|
|
579
|
+
.join('')}</div>`
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
contentEl.innerHTML = html
|
|
583
|
+
sidebar.classList.add('show')
|
|
584
|
+
if (backdrop) backdrop.classList.add('show')
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function drawWeeklyTrend(weekly, commits, onWeekClick) {
|
|
588
|
+
const el = document.getElementById('weeklyTrendChart')
|
|
589
|
+
const isEmpty = hideElementByObj({ el, objectName: weekly })
|
|
590
|
+
if (isEmpty) {
|
|
591
|
+
return null
|
|
592
|
+
}
|
|
593
|
+
if (!Array.isArray(weekly) || weekly.length === 0) {
|
|
594
|
+
return null
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const labels = weekly.map((w) => w.period)
|
|
598
|
+
const dataRate = weekly.map((w) => +(w.outsideWorkRate * 100).toFixed(1)) // %
|
|
599
|
+
const dataCount = weekly.map((w) => w.outsideWorkCount)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
const titleDrawer = el.getAttribute('data-title') || ''
|
|
603
|
+
|
|
604
|
+
const chart = echarts.init(el)
|
|
605
|
+
|
|
606
|
+
chart.setOption({
|
|
607
|
+
tooltip: {
|
|
608
|
+
trigger: 'axis',
|
|
609
|
+
formatter: (params) => {
|
|
610
|
+
const pp = params[0]
|
|
611
|
+
const weekItem = weekly[pp.dataIndex]
|
|
612
|
+
const { start, end } = weekItem.range
|
|
613
|
+
|
|
614
|
+
const rate = params.find((p) => p.seriesName.includes('%'))?.data
|
|
615
|
+
const count = params.find((p) => p.seriesName.includes('次数'))?.data
|
|
616
|
+
|
|
617
|
+
// 加班等级
|
|
618
|
+
let level = '🟢 健康(<10%)'
|
|
619
|
+
if (rate >= 10 && rate < 20) level = '🟠 中度(10–20%)'
|
|
620
|
+
if (rate >= 20) level = '🔴 严重(≥20%)'
|
|
621
|
+
|
|
622
|
+
return `
|
|
623
|
+
<div style="font-size:13px; line-height:1.5;">
|
|
624
|
+
<b>${params[0].axisValue}</b><br/>
|
|
625
|
+
📅 周区间:<b>${start} ~ ${end}</b><br/>
|
|
626
|
+
加班占比:<b>${rate}%</b><br/>
|
|
627
|
+
加班次数:${count} 次<br/>
|
|
628
|
+
等级:${level}
|
|
629
|
+
</div>
|
|
630
|
+
`
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
legend: { top: 10 },
|
|
635
|
+
|
|
636
|
+
xAxis: { type: 'category', data: labels },
|
|
637
|
+
yAxis: [
|
|
638
|
+
{ type: 'value', min: 0, max: 100, name: '占比(%)' },
|
|
639
|
+
{ type: 'value', name: '次数', min: 0 }
|
|
640
|
+
],
|
|
641
|
+
|
|
642
|
+
series: [
|
|
643
|
+
{
|
|
644
|
+
type: 'line',
|
|
645
|
+
name: '加班占比(%)',
|
|
646
|
+
data: dataRate,
|
|
647
|
+
markArea: {
|
|
648
|
+
data: [
|
|
649
|
+
[
|
|
650
|
+
{ yAxis: 0 },
|
|
651
|
+
{ yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } }
|
|
652
|
+
],
|
|
653
|
+
[
|
|
654
|
+
{ yAxis: 10 },
|
|
655
|
+
{ yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } }
|
|
656
|
+
],
|
|
657
|
+
[
|
|
658
|
+
{ yAxis: 20 },
|
|
659
|
+
{ yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } }
|
|
660
|
+
]
|
|
661
|
+
]
|
|
662
|
+
},
|
|
663
|
+
markLine: {
|
|
664
|
+
symbol: ['none', 'arrow'],
|
|
665
|
+
data: [
|
|
666
|
+
{
|
|
667
|
+
yAxis: 10,
|
|
668
|
+
lineStyle: { color: '#fb8c00', width: 2, type: 'dashed' },
|
|
669
|
+
label: { formatter: '10%', color: '#fb8c00' }
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
yAxis: 20,
|
|
673
|
+
lineStyle: { color: '#d32f2f', width: 2, type: 'dashed' },
|
|
674
|
+
label: { formatter: '20%', color: '#d32f2f' }
|
|
675
|
+
}
|
|
676
|
+
]
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
|
|
680
|
+
{
|
|
681
|
+
type: 'line',
|
|
682
|
+
name: '加班次数',
|
|
683
|
+
data: dataCount,
|
|
684
|
+
yAxisIndex: 1,
|
|
685
|
+
smooth: true
|
|
686
|
+
}
|
|
687
|
+
]
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
// ⭐ 点击事件:从 commits 过滤该周提交
|
|
691
|
+
chart.on('click', (p) => {
|
|
692
|
+
const idx = p.dataIndex
|
|
693
|
+
const w = weekly[idx]
|
|
694
|
+
|
|
695
|
+
const start = new Date(w.range.start)
|
|
696
|
+
const end = new Date(w.range.end)
|
|
697
|
+
end.setHours(23, 59, 59, 999) // 包含当天
|
|
698
|
+
|
|
699
|
+
const weeklyCommits = commits.filter((c) => {
|
|
700
|
+
const d = new Date(c.date)
|
|
701
|
+
return d >= start && d <= end
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
// 回调交给外面决定如何打开侧栏
|
|
705
|
+
if (typeof onWeekClick === 'function') {
|
|
706
|
+
// onWeekClick(w.period, w, weeklyCommits)
|
|
707
|
+
onWeekClick({
|
|
708
|
+
period: w.period,
|
|
709
|
+
weeklyItem: w,
|
|
710
|
+
commits: weeklyCommits,
|
|
711
|
+
titleDrawer
|
|
712
|
+
})
|
|
713
|
+
}
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
return chart
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function drawMonthlyTrend(monthly, commits, onMonthClick) {
|
|
720
|
+
const el = document.getElementById('monthlyTrendChart')
|
|
721
|
+
const isEmpty = hideElementByObj({ el, objectName: monthly })
|
|
722
|
+
if (isEmpty) {
|
|
723
|
+
return null
|
|
724
|
+
}
|
|
725
|
+
if (!Array.isArray(monthly) || monthly.length === 0) return null
|
|
726
|
+
|
|
727
|
+
const labels = monthly.map((m) => m.period)
|
|
728
|
+
const dataRate = monthly.map((m) => +(m.outsideWorkRate * 100).toFixed(1)) // 0–100%
|
|
729
|
+
|
|
730
|
+
const titleDrawer = el.getAttribute('data-title') || ''
|
|
731
|
+
// eslint-disable-next-line no-undef
|
|
732
|
+
const chart = echarts.init(el)
|
|
733
|
+
|
|
734
|
+
chart.setOption({
|
|
735
|
+
tooltip: {
|
|
736
|
+
trigger: 'axis',
|
|
737
|
+
formatter: (params) => {
|
|
738
|
+
const p = params[0]
|
|
739
|
+
if (!p) return ''
|
|
740
|
+
|
|
741
|
+
const rate = p.data
|
|
742
|
+
let level = '🟢 健康(<10%)'
|
|
743
|
+
if (rate >= 10 && rate < 20) level = '🟠 中度(10–20%)'
|
|
744
|
+
if (rate >= 20) level = '🔴 严重(≥20%)'
|
|
745
|
+
|
|
746
|
+
return `
|
|
747
|
+
<div style="font-size:13px; line-height:1.5">
|
|
748
|
+
<b>${p.axisValue}</b><br/>
|
|
749
|
+
加班占比:<b>${rate}%</b><br/>
|
|
750
|
+
加班等级:${level}
|
|
751
|
+
</div>
|
|
752
|
+
`
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
|
|
756
|
+
xAxis: { type: 'category', data: labels },
|
|
757
|
+
yAxis: { type: 'value', min: 0, max: 100 },
|
|
758
|
+
|
|
759
|
+
series: [
|
|
760
|
+
{
|
|
761
|
+
type: 'line',
|
|
762
|
+
name: '加班占比(%)',
|
|
763
|
+
data: dataRate,
|
|
764
|
+
|
|
765
|
+
// ⭐ 区间背景(可配置)
|
|
766
|
+
markArea: {
|
|
767
|
+
data: [
|
|
768
|
+
// <10% 绿色轻度
|
|
769
|
+
[
|
|
770
|
+
{ yAxis: 0 },
|
|
771
|
+
{ yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } }
|
|
772
|
+
],
|
|
773
|
+
// 10–20% 橙色中度
|
|
774
|
+
[
|
|
775
|
+
{ yAxis: 10 },
|
|
776
|
+
{ yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } }
|
|
777
|
+
],
|
|
778
|
+
// ≥20% 红色严重
|
|
779
|
+
[
|
|
780
|
+
{ yAxis: 20 },
|
|
781
|
+
{ yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } }
|
|
782
|
+
]
|
|
783
|
+
]
|
|
784
|
+
},
|
|
785
|
+
|
|
786
|
+
// ⭐ 阈值线(同每日图风格)
|
|
787
|
+
markLine: {
|
|
788
|
+
symbol: ['none', 'arrow'],
|
|
789
|
+
data: [
|
|
790
|
+
{
|
|
791
|
+
yAxis: 10,
|
|
792
|
+
lineStyle: {
|
|
793
|
+
color: '#fb8c00',
|
|
794
|
+
width: 2,
|
|
795
|
+
type: 'dashed'
|
|
796
|
+
},
|
|
797
|
+
label: {
|
|
798
|
+
formatter: '10%',
|
|
799
|
+
color: '#fb8c00'
|
|
800
|
+
}
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
yAxis: 20,
|
|
804
|
+
lineStyle: {
|
|
805
|
+
color: '#d32f2f',
|
|
806
|
+
width: 2,
|
|
807
|
+
type: 'dashed'
|
|
808
|
+
},
|
|
809
|
+
label: {
|
|
810
|
+
formatter: '20%',
|
|
811
|
+
color: '#d32f2f'
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
]
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
]
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
// 点击某个月份,打开抽屉显示该月的所有 commits
|
|
821
|
+
if (typeof onMonthClick === 'function' && Array.isArray(commits)) {
|
|
822
|
+
chart.on('click', (params) => {
|
|
823
|
+
const idx = params.dataIndex
|
|
824
|
+
const ym = labels[idx] // 'YYYY-MM'
|
|
825
|
+
const monthCommits = commits.filter((c) => {
|
|
826
|
+
const d = new Date(c.date)
|
|
827
|
+
const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(
|
|
828
|
+
2,
|
|
829
|
+
'0'
|
|
830
|
+
)}`
|
|
831
|
+
return m === ym
|
|
832
|
+
})
|
|
833
|
+
// onMonthClick(ym, monthCommits.length, monthCommits)
|
|
834
|
+
onMonthClick({
|
|
835
|
+
date: ym,
|
|
836
|
+
count: monthCommits.length,
|
|
837
|
+
commits: monthCommits,
|
|
838
|
+
titleDrawer
|
|
839
|
+
})
|
|
840
|
+
})
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
return chart
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function drawLatestHourDaily(latestByDay, commits, onDayClick) {
|
|
847
|
+
const el = document.getElementById('latestHourDailyChart')
|
|
848
|
+
const isEmpty = hideElementByObj({ el, objectName: latestByDay })
|
|
849
|
+
if (isEmpty) {
|
|
850
|
+
return null
|
|
851
|
+
}
|
|
852
|
+
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
|
|
853
|
+
|
|
854
|
+
const labels = latestByDay.map((d) => d.date)
|
|
855
|
+
|
|
856
|
+
const raw = latestByDay.map((d) =>
|
|
857
|
+
typeof d.latestHourNormalized === 'number'
|
|
858
|
+
? d.latestHourNormalized
|
|
859
|
+
: (d.latestHour ?? null)
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
// 数据点颜色
|
|
863
|
+
const data = raw.map((v) => ({
|
|
864
|
+
value: v,
|
|
865
|
+
itemStyle: {
|
|
866
|
+
color:
|
|
867
|
+
// eslint-disable-next-line no-nested-ternary
|
|
868
|
+
v >= 20
|
|
869
|
+
? '#d32f2f' // 红
|
|
870
|
+
: v >= 19
|
|
871
|
+
? '#fb8c00' // 橙
|
|
872
|
+
: '#1976d2' // 蓝
|
|
873
|
+
}
|
|
874
|
+
}))
|
|
875
|
+
|
|
876
|
+
// 获取最大值,用于设置 yAxis 的 max
|
|
877
|
+
const numericValues = raw.filter((v) => typeof v === 'number')
|
|
878
|
+
const maxV = numericValues.length > 0 ? Math.max(...numericValues) : 0
|
|
879
|
+
|
|
880
|
+
const titleDrawer = el.getAttribute('data-title') || ''
|
|
881
|
+
|
|
882
|
+
// eslint-disable-next-line no-undef
|
|
883
|
+
const chart = echarts.init(el)
|
|
884
|
+
|
|
885
|
+
chart.setOption({
|
|
886
|
+
tooltip: {
|
|
887
|
+
trigger: 'axis',
|
|
888
|
+
formatter: (params) => {
|
|
889
|
+
const p = Array.isArray(params) ? params[0] : params
|
|
890
|
+
const v = p?.value != null ? Number(p.value) : null
|
|
891
|
+
const endH = window.__overtimeEndHour || 18
|
|
892
|
+
|
|
893
|
+
if (v == null) {
|
|
894
|
+
return `
|
|
895
|
+
<div style="font-size:13px; line-height:1.5">
|
|
896
|
+
<b>${p.axisValue}</b><br/>
|
|
897
|
+
无数据
|
|
898
|
+
</div>
|
|
899
|
+
`
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const overtime = Math.max(0, v - endH)
|
|
903
|
+
const overtimeText = overtime.toFixed(2)
|
|
904
|
+
|
|
905
|
+
let level = '🟢 正常(无明显加班)'
|
|
906
|
+
if (overtime >= 1 && overtime < 2) level = '🟠 中度加班(1–2h)'
|
|
907
|
+
if (overtime >= 2) level = '🔴 严重加班(≥2h)'
|
|
908
|
+
|
|
909
|
+
return `
|
|
910
|
+
<div style="font-size:13px; line-height:1.5">
|
|
911
|
+
<b>${p.axisValue}</b><br/>
|
|
912
|
+
最晚提交时间:<b>${v.toFixed(2)} 点</b><br/>
|
|
913
|
+
超出下班:<b>${overtimeText} 小时</b><br/>
|
|
914
|
+
加班等级:${level}
|
|
915
|
+
</div>
|
|
916
|
+
`
|
|
917
|
+
}
|
|
918
|
+
},
|
|
919
|
+
xAxis: { type: 'category', data: labels },
|
|
920
|
+
yAxis: {
|
|
921
|
+
type: 'value',
|
|
922
|
+
min: 0,
|
|
923
|
+
max: Math.max(26, Math.ceil(maxV + 1))
|
|
924
|
+
},
|
|
925
|
+
series: [
|
|
926
|
+
{
|
|
927
|
+
type: 'line',
|
|
928
|
+
name: '每日最晚提交小时',
|
|
929
|
+
data,
|
|
930
|
+
// 让折线在 null 点之间连起来,避免视觉上“断裂”
|
|
931
|
+
connectNulls: true,
|
|
932
|
+
markLine: {
|
|
933
|
+
symbol: ['none', 'arrow'],
|
|
934
|
+
data: [
|
|
935
|
+
// 20 小时线(橙色)
|
|
936
|
+
{
|
|
937
|
+
yAxis: 19,
|
|
938
|
+
lineStyle: {
|
|
939
|
+
color: '#fb8c00',
|
|
940
|
+
width: 2,
|
|
941
|
+
type: 'solid'
|
|
942
|
+
},
|
|
943
|
+
label: {
|
|
944
|
+
formatter: '21h',
|
|
945
|
+
color: '#fb8c00'
|
|
946
|
+
}
|
|
947
|
+
},
|
|
948
|
+
// 21 小时线(红色)
|
|
949
|
+
{
|
|
950
|
+
yAxis: 20,
|
|
951
|
+
lineStyle: {
|
|
952
|
+
color: '#d32f2f',
|
|
953
|
+
width: 2,
|
|
954
|
+
type: 'solid'
|
|
955
|
+
},
|
|
956
|
+
label: {
|
|
957
|
+
formatter: '24h',
|
|
958
|
+
color: '#d32f2f'
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
]
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
]
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
// 点击某一天的最晚提交时间点,打开抽屉显示该日 commits
|
|
968
|
+
if (typeof onDayClick === 'function' && Array.isArray(commits)) {
|
|
969
|
+
// 预聚合:按天收集 commits
|
|
970
|
+
const dayCommitsMap = {}
|
|
971
|
+
commits.forEach((c) => {
|
|
972
|
+
const d = new Date(c.date).toISOString().slice(0, 10)
|
|
973
|
+
if (!dayCommitsMap[d]) dayCommitsMap[d] = []
|
|
974
|
+
dayCommitsMap[d].push(c)
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
chart.on('click', (params) => {
|
|
978
|
+
const idx = params.dataIndex
|
|
979
|
+
const date = labels[idx]
|
|
980
|
+
const list = dayCommitsMap[date] || []
|
|
981
|
+
// onDayClick(date, list.length, list)
|
|
982
|
+
onDayClick({
|
|
983
|
+
date,
|
|
984
|
+
count: list.length,
|
|
985
|
+
commits: list,
|
|
986
|
+
titleDrawer
|
|
987
|
+
})
|
|
988
|
+
})
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return chart
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function drawDailySeverity(latestByDay, commits, onDayClick) {
|
|
995
|
+
const el = document.getElementById('dailySeverityChart')
|
|
996
|
+
const isEmpty = hideElementByObj({ el, objectName: latestByDay })
|
|
997
|
+
if (isEmpty) {
|
|
998
|
+
return null
|
|
999
|
+
}
|
|
1000
|
+
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
|
|
1001
|
+
|
|
1002
|
+
const labels = latestByDay.map((d) => d.date)
|
|
1003
|
+
const endH = window.__overtimeEndHour || 18
|
|
1004
|
+
|
|
1005
|
+
const raw = latestByDay.map((d) =>
|
|
1006
|
+
typeof d.latestHourNormalized === 'number'
|
|
1007
|
+
? d.latestHourNormalized
|
|
1008
|
+
: (d.latestHour ?? null)
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
// 若某天 latestHourNormalized 为空,表示「没有下班后到次日上班前的提交」,
|
|
1012
|
+
// 这里按 0 小时加班处理,保证折线连续。
|
|
1013
|
+
const sev = raw.map((v) => (v == null ? 0 : Math.max(0, Number(v) - endH)))
|
|
1014
|
+
|
|
1015
|
+
const titleDrawer = el.getAttribute('data-title') || ''
|
|
1016
|
+
|
|
1017
|
+
// eslint-disable-next-line no-undef
|
|
1018
|
+
const chart = echarts.init(el)
|
|
1019
|
+
|
|
1020
|
+
chart.setOption({
|
|
1021
|
+
tooltip: {
|
|
1022
|
+
trigger: 'axis',
|
|
1023
|
+
formatter: (params) => {
|
|
1024
|
+
const p = params[0]
|
|
1025
|
+
if (!p) return ''
|
|
1026
|
+
const date = p.axisValue
|
|
1027
|
+
const overtime = p.data
|
|
1028
|
+
const rawHour = raw[p.dataIndex] // 原始 latestHour 或 latestHourNormalized
|
|
1029
|
+
|
|
1030
|
+
return `
|
|
1031
|
+
<div style="font-size:13px;">
|
|
1032
|
+
<b>${date}</b><br/>
|
|
1033
|
+
下班后:<b>${overtime.toFixed(2)} 小时</b><br/>
|
|
1034
|
+
原始最晚提交:${rawHour != null ? `${rawHour.toFixed(2)} 点` : '无'}<br/>
|
|
1035
|
+
加班等级:${
|
|
1036
|
+
// eslint-disable-next-line no-nested-ternary
|
|
1037
|
+
overtime < 1
|
|
1038
|
+
? '🟢 0–1 小时(轻度)'
|
|
1039
|
+
: overtime < 2
|
|
1040
|
+
? '🟠 1–2 小时(中度)'
|
|
1041
|
+
: '🔴 ≥2 小时(严重)'
|
|
1042
|
+
}
|
|
1043
|
+
</div>
|
|
1044
|
+
`
|
|
1045
|
+
}
|
|
1046
|
+
},
|
|
1047
|
+
|
|
1048
|
+
xAxis: { type: 'category', data: labels },
|
|
1049
|
+
yAxis: { type: 'value', min: 0 },
|
|
1050
|
+
|
|
1051
|
+
series: [
|
|
1052
|
+
{
|
|
1053
|
+
type: 'line',
|
|
1054
|
+
name: '超过下班小时数',
|
|
1055
|
+
data: sev,
|
|
1056
|
+
// 连续显示 0 小时加班的日期,避免折线断开
|
|
1057
|
+
connectNulls: true,
|
|
1058
|
+
|
|
1059
|
+
// ⭐ 加班区域背景
|
|
1060
|
+
markArea: {
|
|
1061
|
+
data: [
|
|
1062
|
+
// 0–1h:透明
|
|
1063
|
+
[{ yAxis: 0 }, { yAxis: 1, itemStyle: { color: 'rgba(0,0,0,0)' } }],
|
|
1064
|
+
// 1–2h:半透明橙色
|
|
1065
|
+
[
|
|
1066
|
+
{ yAxis: 1 },
|
|
1067
|
+
{ yAxis: 2, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } } // #fb8c00
|
|
1068
|
+
],
|
|
1069
|
+
// ≥2h:半透明红色
|
|
1070
|
+
[
|
|
1071
|
+
{ yAxis: 2 },
|
|
1072
|
+
{ yAxis: 10, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } } // #d32f2f
|
|
1073
|
+
]
|
|
1074
|
+
]
|
|
1075
|
+
},
|
|
1076
|
+
|
|
1077
|
+
// ⭐ 超时阈值标线
|
|
1078
|
+
markLine: {
|
|
1079
|
+
symbol: ['none', 'arrow'],
|
|
1080
|
+
data: [
|
|
1081
|
+
{
|
|
1082
|
+
yAxis: 1,
|
|
1083
|
+
lineStyle: {
|
|
1084
|
+
color: '#fb8c00',
|
|
1085
|
+
width: 2,
|
|
1086
|
+
type: 'dashed'
|
|
1087
|
+
},
|
|
1088
|
+
label: { formatter: '1h', color: '#fb8c00' }
|
|
1089
|
+
},
|
|
1090
|
+
{
|
|
1091
|
+
yAxis: 2,
|
|
1092
|
+
lineStyle: {
|
|
1093
|
+
color: '#d32f2f',
|
|
1094
|
+
width: 2,
|
|
1095
|
+
type: 'dashed'
|
|
1096
|
+
},
|
|
1097
|
+
label: { formatter: '2h', color: '#d32f2f' }
|
|
1098
|
+
}
|
|
1099
|
+
]
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
]
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
// 点击某一天的「超过下班小时数」点,打开抽屉显示该日 commits
|
|
1106
|
+
if (typeof onDayClick === 'function' && Array.isArray(commits)) {
|
|
1107
|
+
const dayCommitsMap = {}
|
|
1108
|
+
commits.forEach((c) => {
|
|
1109
|
+
const d = new Date(c.date).toISOString().slice(0, 10)
|
|
1110
|
+
if (!dayCommitsMap[d]) dayCommitsMap[d] = []
|
|
1111
|
+
dayCommitsMap[d].push(c)
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
chart.on('click', (params) => {
|
|
1115
|
+
const idx = params.dataIndex
|
|
1116
|
+
const date = labels[idx]
|
|
1117
|
+
const list = dayCommitsMap[date] || []
|
|
1118
|
+
// onDayClick(date, list.length, list)
|
|
1119
|
+
onDayClick({
|
|
1120
|
+
date,
|
|
1121
|
+
count: list.length,
|
|
1122
|
+
commits: list,
|
|
1123
|
+
titleDrawer
|
|
1124
|
+
})
|
|
1125
|
+
})
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
return chart
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* 绘制每日趋势(带加班严重度背景区间)并自动分析最累的日期
|
|
1133
|
+
* @param {Array} commits - 原始提交记录(包含 c.date)
|
|
1134
|
+
* @param {Function} onDayClick - 用户点击某一天时的回调 (date, count) => void
|
|
1135
|
+
*/
|
|
1136
|
+
/**
|
|
1137
|
+
* 绘制每日趋势(含严重度背景区间、最累标记、tooltip 明细)
|
|
1138
|
+
*/
|
|
1139
|
+
function drawDailyTrendSeverity(commits, weekly, onDayClick) {
|
|
1140
|
+
// ---------- 1. 聚合每日数据 ----------
|
|
1141
|
+
const dayMap = new Map()
|
|
1142
|
+
const dayCommitsDetail = {}
|
|
1143
|
+
|
|
1144
|
+
commits.forEach((c) => {
|
|
1145
|
+
const d = new Date(c.date).toISOString().slice(0, 10)
|
|
1146
|
+
|
|
1147
|
+
// 数量统计
|
|
1148
|
+
dayMap.set(d, (dayMap.get(d) || 0) + 1)
|
|
1149
|
+
|
|
1150
|
+
// 详细信息统计(用于 tooltip 显示)
|
|
1151
|
+
if (!dayCommitsDetail[d]) dayCommitsDetail[d] = []
|
|
1152
|
+
dayCommitsDetail[d].push({
|
|
1153
|
+
author: c.author,
|
|
1154
|
+
time: c.date,
|
|
1155
|
+
msg: c.message
|
|
1156
|
+
})
|
|
1157
|
+
})
|
|
1158
|
+
|
|
1159
|
+
const labels = Array.from(dayMap.keys()).sort()
|
|
1160
|
+
const data = labels.map((l) => dayMap.get(l))
|
|
1161
|
+
|
|
1162
|
+
// ---------- 2. 自动分析「最累的一天」 ----------
|
|
1163
|
+
const maxDailyCount = Math.max(...data)
|
|
1164
|
+
const maxDailyIndex = data.indexOf(maxDailyCount)
|
|
1165
|
+
const mostTiredDay = labels[maxDailyIndex]
|
|
1166
|
+
|
|
1167
|
+
document.getElementById('mostTiredDay').innerHTML =
|
|
1168
|
+
`🔥 最累的一天:<b>${mostTiredDay}</b>(${maxDailyCount} 次提交)`
|
|
1169
|
+
|
|
1170
|
+
// ---------- 3. 自动分析「最累的一周」 ----------
|
|
1171
|
+
let maxWeek = null
|
|
1172
|
+
if (Array.isArray(weekly) && weekly.length > 0) {
|
|
1173
|
+
maxWeek = weekly.reduce((a, b) =>
|
|
1174
|
+
a.outsideWorkCount > b.outsideWorkCount ? a : b
|
|
1175
|
+
)
|
|
1176
|
+
if (maxWeek) {
|
|
1177
|
+
document.getElementById('mostTiredWeek').innerHTML =
|
|
1178
|
+
`🔥 最累的一周:<b>${maxWeek.period}</b>(${maxWeek.outsideWorkCount} 次加班)`
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// ---------- 4. 自动分析「最累的月份」 ----------
|
|
1183
|
+
const monthMap = new Map()
|
|
1184
|
+
commits.forEach((c) => {
|
|
1185
|
+
const d = new Date(c.date)
|
|
1186
|
+
const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
1187
|
+
monthMap.set(ym, (monthMap.get(ym) || 0) + 1)
|
|
1188
|
+
})
|
|
1189
|
+
|
|
1190
|
+
const mostTiredMonth = Array.from(monthMap.entries()).sort(
|
|
1191
|
+
(a, b) => b[1] - a[1]
|
|
1192
|
+
)[0]
|
|
1193
|
+
|
|
1194
|
+
document.getElementById('mostTiredMonth').innerHTML =
|
|
1195
|
+
`🔥 最累的月份:<b>${mostTiredMonth[0]}</b>(${mostTiredMonth[1]} 次提交)`
|
|
1196
|
+
|
|
1197
|
+
// ---------- 5. 背景严重度区块 ----------
|
|
1198
|
+
const markArea = {
|
|
1199
|
+
silent: true,
|
|
1200
|
+
itemStyle: { opacity: 0.15 },
|
|
1201
|
+
data: [
|
|
1202
|
+
[{ name: '0–5 次', yAxis: 0 }, { yAxis: 5 }],
|
|
1203
|
+
[
|
|
1204
|
+
{ name: '5–10 次', yAxis: 5 },
|
|
1205
|
+
{ yAxis: 10, itemStyle: { color: 'orange', opacity: 0.25 } }
|
|
1206
|
+
],
|
|
1207
|
+
[
|
|
1208
|
+
{ name: '10 次以上', yAxis: 10 },
|
|
1209
|
+
{ yAxis: 999, itemStyle: { color: 'red', opacity: 0.25 } }
|
|
1210
|
+
]
|
|
1211
|
+
]
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// ---------- 6. 构造 tooltip ----------
|
|
1215
|
+
const tooltipFormatter = (params) => {
|
|
1216
|
+
const date = params?.[0].name
|
|
1217
|
+
const count = params?.[0].value
|
|
1218
|
+
const details = dayCommitsDetail[date] || []
|
|
1219
|
+
|
|
1220
|
+
let html = `📅 <b>${date}</b><br/>提交次数:${count}<br/><br/>`
|
|
1221
|
+
|
|
1222
|
+
details.slice(0, 5).forEach((d) => {
|
|
1223
|
+
html += `👤 ${d.author}<br/>🕒 ${d.time}<br/>💬 ${d.msg}<br/><br/>`
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
if (details.length > 5) {
|
|
1227
|
+
html += `(其余 ${details.length - 5} 条已省略)`
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
return html
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// ---------- 7. 绘图 ----------
|
|
1234
|
+
const el = document.getElementById('dailyTrendChartDog')
|
|
1235
|
+
const titleDrawer = el.getAttribute('data-title') || ''
|
|
1236
|
+
|
|
1237
|
+
const chart = echarts.init(el)
|
|
1238
|
+
|
|
1239
|
+
chart.setOption({
|
|
1240
|
+
tooltip: {
|
|
1241
|
+
trigger: 'axis',
|
|
1242
|
+
formatter: tooltipFormatter,
|
|
1243
|
+
axisPointer: { type: 'shadow' }
|
|
1244
|
+
},
|
|
1245
|
+
xAxis: { type: 'category', data: labels },
|
|
1246
|
+
yAxis: { type: 'value', min: 0 },
|
|
1247
|
+
series: [
|
|
1248
|
+
{
|
|
1249
|
+
type: 'line',
|
|
1250
|
+
name: '每日提交',
|
|
1251
|
+
data,
|
|
1252
|
+
areaStyle: {},
|
|
1253
|
+
markArea,
|
|
1254
|
+
markPoint: {
|
|
1255
|
+
data: [
|
|
1256
|
+
{
|
|
1257
|
+
name: '最累的一天',
|
|
1258
|
+
coord: [mostTiredDay, maxDailyCount],
|
|
1259
|
+
value: maxDailyCount,
|
|
1260
|
+
symbolSize: 70,
|
|
1261
|
+
itemStyle: { color: '#ff4d4f' },
|
|
1262
|
+
label: { formatter: '🔥 最累' }
|
|
1263
|
+
}
|
|
1264
|
+
]
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
]
|
|
1268
|
+
})
|
|
1269
|
+
|
|
1270
|
+
// ---------- 8. 点击事件 ----------
|
|
1271
|
+
if (typeof onDayClick === 'function') {
|
|
1272
|
+
chart.on('click', (params) => {
|
|
1273
|
+
if (params.componentType === 'series') {
|
|
1274
|
+
const date = labels[params.dataIndex]
|
|
1275
|
+
const count = data[params.dataIndex]
|
|
1276
|
+
// onDayClick(date, count, dayCommitsDetail[date])
|
|
1277
|
+
onDayClick({
|
|
1278
|
+
date,
|
|
1279
|
+
count,
|
|
1280
|
+
commits: dayCommitsDetail[date],
|
|
1281
|
+
titleDrawer
|
|
1282
|
+
})
|
|
1283
|
+
}
|
|
1284
|
+
})
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
return {
|
|
1288
|
+
chart,
|
|
1289
|
+
analysis: {
|
|
1290
|
+
mostTiredDay,
|
|
1291
|
+
mostTiredMonth,
|
|
1292
|
+
mostTiredWeek: maxWeek
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function showDayDetailSidebar({ date, count, commits, titleDrawer }) {
|
|
1298
|
+
const sidebar = document.getElementById('dayDetailSidebar')
|
|
1299
|
+
const backdrop = document.getElementById('sidebarBackdrop')
|
|
1300
|
+
const title = document.getElementById('sidebarTitle')
|
|
1301
|
+
const content = document.getElementById('sidebarContent')
|
|
1302
|
+
const drawerTitleEl = document.getElementById('sidebarDrawerTitle')
|
|
1303
|
+
|
|
1304
|
+
title.innerHTML = `📅 ${date}(${count} 次提交)`
|
|
1305
|
+
drawerTitleEl.innerHTML = titleDrawer || ''
|
|
1306
|
+
|
|
1307
|
+
// 渲染详情
|
|
1308
|
+
content.innerHTML = commits
|
|
1309
|
+
.map(
|
|
1310
|
+
(c) => `
|
|
1311
|
+
<div class="sidebar-item">
|
|
1312
|
+
<div class="sidebar-item-header">
|
|
1313
|
+
<span class="author">👤 ${escapeHtml(c.author || 'unknown')}</span>
|
|
1314
|
+
<span class="time">🕒 ${escapeHtml(c.time || c.date || '')}</span>
|
|
1315
|
+
</div>
|
|
1316
|
+
<div class="sidebar-item-message">${escapeHtml(c.msg || c.message || '')}</div>
|
|
1317
|
+
</div>
|
|
1318
|
+
`
|
|
1319
|
+
)
|
|
1320
|
+
.join('')
|
|
1321
|
+
|
|
1322
|
+
sidebar.classList.add('show')
|
|
1323
|
+
if (backdrop) backdrop.classList.add('show')
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function renderKpi(stats) {
|
|
1327
|
+
const el = document.getElementById('kpiContent')
|
|
1328
|
+
if (!el || !stats) return
|
|
1329
|
+
const latest = stats.latestCommit
|
|
1330
|
+
const latestHour = stats.latestCommitHour
|
|
1331
|
+
|
|
1332
|
+
// 使用 cutoff + 上下班时间,重新在全部 commits 中计算「加班最晚一次提交」
|
|
1333
|
+
const cutoff = window.__overnightCutoff ?? 6
|
|
1334
|
+
const startHour =
|
|
1335
|
+
typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
1336
|
+
? stats.startHour
|
|
1337
|
+
: 9
|
|
1338
|
+
const endHour =
|
|
1339
|
+
typeof stats.endHour === 'number' && stats.endHour >= 0
|
|
1340
|
+
? stats.endHour
|
|
1341
|
+
: (window.__overtimeEndHour ?? 18)
|
|
1342
|
+
|
|
1343
|
+
let latestOut = null
|
|
1344
|
+
let latestOutHour = null
|
|
1345
|
+
let maxSeverity = -1
|
|
1346
|
+
|
|
1347
|
+
if (Array.isArray(commitsAll) && commitsAll.length > 0) {
|
|
1348
|
+
commitsAll.forEach((c) => {
|
|
1349
|
+
const d = new Date(c.date)
|
|
1350
|
+
if (!d || Number.isNaN(d.valueOf())) return
|
|
1351
|
+
const h = d.getHours()
|
|
1352
|
+
|
|
1353
|
+
// 只看「当日下班后」以及「次日凌晨 cutoff 之前,且仍在上班前」的提交
|
|
1354
|
+
let sev = null
|
|
1355
|
+
if (h >= endHour && h < 24) {
|
|
1356
|
+
// 当晚:直接按 h - endHour 计算
|
|
1357
|
+
sev = h - endHour
|
|
1358
|
+
} else if (h >= 0 && h < cutoff && h < startHour) {
|
|
1359
|
+
// 次日凌晨:视作跨天,加上 24
|
|
1360
|
+
sev = 24 - endHour + h
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
if (sev != null && sev >= 0 && sev > maxSeverity) {
|
|
1364
|
+
maxSeverity = sev
|
|
1365
|
+
latestOut = c
|
|
1366
|
+
latestOutHour = h
|
|
1367
|
+
}
|
|
1368
|
+
})
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// 若按 cutoff 没算出结果,则退回到原来的 stats.latestOutsideCommit
|
|
1372
|
+
if (!latestOut && stats.latestOutsideCommit) {
|
|
1373
|
+
latestOut = stats.latestOutsideCommit
|
|
1374
|
+
latestOutHour =
|
|
1375
|
+
stats.latestOutsideCommitHour ??
|
|
1376
|
+
(latestOut ? new Date(latestOut.date).getHours() : null)
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const htmlLatest = latest
|
|
1380
|
+
? `<div>最后一次提交时间:${latest ? formatDate(latest.date) : '-'}${typeof latestHour === 'number' ? `(${String(latestHour).padStart(2, '0')}:00)` : ''} <div class="author">${latest?.author}</div> <div> ${latest?.message} <div></div>`
|
|
1381
|
+
: ``
|
|
1382
|
+
const html = [
|
|
1383
|
+
htmlLatest,
|
|
1384
|
+
`<div class="hr"></div>`,
|
|
1385
|
+
`<div>加班最晚一次提交时间:${latestOut ? formatDate(latestOut.date) : '-'}${typeof latestOutHour === 'number' ? `(${String(latestOutHour).padStart(2, '0')}:00)` : ''} <div class="author">${latestOut.author}</div> <div>${latestOut.message}</div> </div>`,
|
|
1386
|
+
`<div class="hr"></div>`,
|
|
1387
|
+
`<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div>`
|
|
1388
|
+
].join('')
|
|
1389
|
+
el.innerHTML = html
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// 1) 按小时分组(例:commits 为原始提交数组)
|
|
1393
|
+
function groupCommitsByHour(commits) {
|
|
1394
|
+
const byHour = Array.from({ length: 24 }, () => [])
|
|
1395
|
+
commits.forEach((c) => {
|
|
1396
|
+
// 解析 commit 的本地小时(考虑时区已有 '+0800' 等)
|
|
1397
|
+
const d = new Date(c.date)
|
|
1398
|
+
const h = d.getHours() // 若数据已为 UTC,请按需求调整
|
|
1399
|
+
byHour[h].push(c)
|
|
1400
|
+
})
|
|
1401
|
+
return byHour
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// 基于 latestByDay + cutoff/endHour 统计「最晚加班的一天 / 一周 / 一月」
|
|
1405
|
+
function computeAndRenderLatestOvertime(latestByDay) {
|
|
1406
|
+
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return
|
|
1407
|
+
|
|
1408
|
+
const endH = window.__overtimeEndHour || 18
|
|
1409
|
+
|
|
1410
|
+
// 每天的 latestHourNormalized → 超出下班的小时数
|
|
1411
|
+
const dailyOvertime = latestByDay
|
|
1412
|
+
.map((d) => {
|
|
1413
|
+
let v = null
|
|
1414
|
+
if (typeof d.latestHourNormalized === 'number') {
|
|
1415
|
+
v = d.latestHourNormalized
|
|
1416
|
+
} else if (typeof d.latestHour === 'number') {
|
|
1417
|
+
v = d.latestHour
|
|
1418
|
+
}
|
|
1419
|
+
if (v == null) return null
|
|
1420
|
+
const overtime = Math.max(0, Number(v) - endH)
|
|
1421
|
+
return { date: d.date, overtime, raw: v }
|
|
1422
|
+
})
|
|
1423
|
+
.filter(Boolean)
|
|
1424
|
+
|
|
1425
|
+
if (!dailyOvertime.length) return
|
|
1426
|
+
|
|
1427
|
+
// 1) 最晚加班的一天(超出下班小时数最大,若相同取日期更晚)
|
|
1428
|
+
const dailySorted = [...dailyOvertime].sort((a, b) => {
|
|
1429
|
+
if (b.overtime !== a.overtime) return b.overtime - a.overtime
|
|
1430
|
+
return new Date(b.date) - new Date(a.date)
|
|
1431
|
+
})
|
|
1432
|
+
const worstDay = dailySorted[0]
|
|
1433
|
+
const dayEl = document.getElementById('latestOvertimeDay')
|
|
1434
|
+
if (dayEl) {
|
|
1435
|
+
dayEl.innerHTML = `⏰ 最晚加班的一天:<b>${worstDay.date}</b>(超过下班 <b>${worstDay.overtime.toFixed(
|
|
1436
|
+
2
|
|
1437
|
+
)}</b> 小时,逻辑时间约 ${worstDay.raw.toFixed(2)} 点)`
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// 2) 按周聚合:每周取「该周内任意一天的最大加班时长」
|
|
1441
|
+
const weekMap = new Map()
|
|
1442
|
+
dailyOvertime.forEach((d) => {
|
|
1443
|
+
const key = getIsoWeekKey(d.date)
|
|
1444
|
+
if (!key) return
|
|
1445
|
+
const cur = weekMap.get(key)
|
|
1446
|
+
if (!cur || d.overtime > cur.overtime) {
|
|
1447
|
+
weekMap.set(key, d)
|
|
1448
|
+
}
|
|
1449
|
+
})
|
|
1450
|
+
|
|
1451
|
+
if (weekMap.size) {
|
|
1452
|
+
const weeks = Array.from(weekMap.entries()).sort((a, b) => {
|
|
1453
|
+
if (b[1].overtime !== a[1].overtime) return b[1].overtime - a[1].overtime
|
|
1454
|
+
return new Date(b[1].date) - new Date(a[1].date)
|
|
1455
|
+
})
|
|
1456
|
+
const [weekKey, weekInfo] = weeks[0]
|
|
1457
|
+
const weekEl = document.getElementById('latestOvertimeWeek')
|
|
1458
|
+
if (weekEl) {
|
|
1459
|
+
weekEl.innerHTML = `⏰ 最晚加班的一周:<b>${weekKey}</b>(代表日期 ${weekInfo.date},超过下班 <b>${weekInfo.overtime.toFixed(
|
|
1460
|
+
2
|
|
1461
|
+
)}</b> 小时)`
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// 3) 按月聚合:每月取「该月任意一天的最大加班时长」
|
|
1466
|
+
const monthMap = new Map()
|
|
1467
|
+
dailyOvertime.forEach((d) => {
|
|
1468
|
+
const dt = new Date(d.date)
|
|
1469
|
+
if (Number.isNaN(dt.valueOf())) return
|
|
1470
|
+
const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(
|
|
1471
|
+
2,
|
|
1472
|
+
'0'
|
|
1473
|
+
)}`
|
|
1474
|
+
const cur = monthMap.get(key)
|
|
1475
|
+
if (!cur || d.overtime > cur.overtime) {
|
|
1476
|
+
monthMap.set(key, d)
|
|
1477
|
+
}
|
|
1478
|
+
})
|
|
1479
|
+
|
|
1480
|
+
if (monthMap.size) {
|
|
1481
|
+
const months = Array.from(monthMap.entries()).sort((a, b) => {
|
|
1482
|
+
if (b[1].overtime !== a[1].overtime) return b[1].overtime - a[1].overtime
|
|
1483
|
+
return new Date(b[1].date) - new Date(a[1].date)
|
|
1484
|
+
})
|
|
1485
|
+
const [monthKey, monthInfo] = months[0]
|
|
1486
|
+
const monthEl = document.getElementById('latestOvertimeMonth')
|
|
1487
|
+
if (monthEl) {
|
|
1488
|
+
monthEl.innerHTML = `⏰ 最晚加班的月份:<b>${monthKey}</b>(代表日期 ${monthInfo.date},超过下班 <b>${monthInfo.overtime.toFixed(
|
|
1489
|
+
2
|
|
1490
|
+
)}</b> 小时)`
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function buildDataset(stats, type) {
|
|
1496
|
+
const dataMap = stats[type] // { author: { period: changed } }
|
|
1497
|
+
|
|
1498
|
+
const authors = Object.keys(dataMap)
|
|
1499
|
+
const allPeriods = Array.from(
|
|
1500
|
+
new Set(authors.flatMap((a) => Object.keys(dataMap[a])))
|
|
1501
|
+
).sort()
|
|
1502
|
+
|
|
1503
|
+
const series = authors.map((a) => ({
|
|
1504
|
+
name: a,
|
|
1505
|
+
type: 'line',
|
|
1506
|
+
smooth: true,
|
|
1507
|
+
data: allPeriods.map((p) => dataMap[a][p] || 0)
|
|
1508
|
+
}))
|
|
1509
|
+
|
|
1510
|
+
return { authors, allPeriods, series }
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const drawChangeTrends = (stats) => {
|
|
1514
|
+
const el = document.getElementById('chartAuthorChanges')
|
|
1515
|
+
if (!el) return null
|
|
1516
|
+
const chart = echarts.init(el)
|
|
1517
|
+
|
|
1518
|
+
function render(type) {
|
|
1519
|
+
const { authors, allPeriods, series } = buildDataset(stats, type)
|
|
1520
|
+
const ds = { authors, allPeriods, series }
|
|
1521
|
+
ds.rangeMap = {}
|
|
1522
|
+
|
|
1523
|
+
for (const period of ds.allPeriods) {
|
|
1524
|
+
if (period.includes('-W')) {
|
|
1525
|
+
const [yy, ww] = period.split('-W')
|
|
1526
|
+
ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
chart.setOption({
|
|
1530
|
+
// tooltip: { trigger: 'axis' },
|
|
1531
|
+
tooltip: {
|
|
1532
|
+
trigger: 'axis',
|
|
1533
|
+
formatter(params) {
|
|
1534
|
+
if (!params || !params.length) return ''
|
|
1535
|
+
|
|
1536
|
+
const p = params[0]
|
|
1537
|
+
const label = p.axisValue
|
|
1538
|
+
const isWeekly = type === 'weekly'
|
|
1539
|
+
|
|
1540
|
+
let extra = ''
|
|
1541
|
+
if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
|
|
1542
|
+
const { start, end } = ds.rangeMap[label]
|
|
1543
|
+
// extra = `<div style="margin-top:4px;color:#999;font-size:12px">
|
|
1544
|
+
// 周区间:${start} ~ ${end}
|
|
1545
|
+
// </div>`
|
|
1546
|
+
extra = ''
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const lines = params
|
|
1550
|
+
.filter((i) => i.data > 0)
|
|
1551
|
+
.sort((a, b) => (b.data || 0) - (a.data || 0) || String(a.seriesName).localeCompare(String(b.seriesName)))
|
|
1552
|
+
.map(
|
|
1553
|
+
(item) => `${item.marker}${item.seriesName}: ${item.data} 行变更`
|
|
1554
|
+
)
|
|
1555
|
+
.join('<br/>')
|
|
1556
|
+
|
|
1557
|
+
return `
|
|
1558
|
+
<div>${label}</div>
|
|
1559
|
+
${extra}
|
|
1560
|
+
${lines}
|
|
1561
|
+
`
|
|
1562
|
+
}
|
|
1563
|
+
},
|
|
1564
|
+
legend: { data: authors },
|
|
1565
|
+
xAxis: { type: 'category', data: allPeriods },
|
|
1566
|
+
yAxis: { type: 'value' },
|
|
1567
|
+
series
|
|
1568
|
+
})
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// 初次渲染:日
|
|
1572
|
+
render('daily')
|
|
1573
|
+
|
|
1574
|
+
// tabs 切换
|
|
1575
|
+
const tabs = document.querySelectorAll('#tabs button')
|
|
1576
|
+
tabs.forEach((btnEl) => {
|
|
1577
|
+
btnEl.addEventListener('click', () => {
|
|
1578
|
+
tabs.forEach((b) => b.classList.remove('active'))
|
|
1579
|
+
btnEl.classList.add('active')
|
|
1580
|
+
render(btnEl.dataset.type)
|
|
1581
|
+
})
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1584
|
+
// 点击事件:点击某个作者在某个周期的点,打开侧栏显示该作者在该周期的 commits
|
|
1585
|
+
chart.on('click', (p) => {
|
|
1586
|
+
try {
|
|
1587
|
+
if (!p || p.componentType !== 'series') return
|
|
1588
|
+
const label = p.axisValue || p.name
|
|
1589
|
+
const author = p.seriesName
|
|
1590
|
+
if (!label || !author) return
|
|
1591
|
+
const type = document.querySelector('#tabs button.active')?.dataset.type || 'daily'
|
|
1592
|
+
|
|
1593
|
+
const filteredCommits = (Array.isArray(commitsAll) ? commitsAll : []).filter((c) => {
|
|
1594
|
+
const a = c.author || 'unknown'
|
|
1595
|
+
if (a !== author) return false
|
|
1596
|
+
const d = new Date(c.date)
|
|
1597
|
+
if (Number.isNaN(d.valueOf())) return false
|
|
1598
|
+
if (type === 'daily') return d.toISOString().slice(0, 10) === label
|
|
1599
|
+
if (type === 'weekly') {
|
|
1600
|
+
if (!label.includes('-W')) return false
|
|
1601
|
+
const [yy, ww] = label.split('-W')
|
|
1602
|
+
const range = getISOWeekRange(Number(yy), Number(ww))
|
|
1603
|
+
const day = d.toISOString().slice(0, 10)
|
|
1604
|
+
return day >= range.start && day <= range.end
|
|
1605
|
+
}
|
|
1606
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
1607
|
+
return month === label
|
|
1608
|
+
})
|
|
1609
|
+
|
|
1610
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
1611
|
+
|
|
1612
|
+
if (type === 'weekly') {
|
|
1613
|
+
const weeklyItem = { outsideWorkCount: filteredCommits.length, outsideWorkRate: 0 }
|
|
1614
|
+
showSideBarForWeek({ period: label, weeklyItem, commits: filteredCommits, titleDrawer: `${author} 变更量 ${type} 详情` })
|
|
1615
|
+
} else {
|
|
1616
|
+
showDayDetailSidebar({ date: label, count: filteredCommits.length, commits: filteredCommits, titleDrawer: `${author} 变更量 ${type} 详情` })
|
|
1617
|
+
}
|
|
1618
|
+
} catch (err) {
|
|
1619
|
+
console.warn('Change chart click handler error', err)
|
|
1620
|
+
}
|
|
1621
|
+
})
|
|
1622
|
+
|
|
1623
|
+
return chart
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// ========= 开发者加班趋势(基于 commits 现场计算) =========
|
|
1627
|
+
function buildAuthorOvertimeDataset(commits, type, startHour, endHour, cutoff) {
|
|
1628
|
+
const byAuthor = new Map()
|
|
1629
|
+
const periods = new Set()
|
|
1630
|
+
|
|
1631
|
+
commits.forEach((c) => {
|
|
1632
|
+
const d = new Date(c.date)
|
|
1633
|
+
if (Number.isNaN(d.valueOf())) return
|
|
1634
|
+
const h = d.getHours()
|
|
1635
|
+
const isOvertime =
|
|
1636
|
+
(h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
|
|
1637
|
+
if (!isOvertime) return
|
|
1638
|
+
|
|
1639
|
+
let key
|
|
1640
|
+
if (type === 'daily') {
|
|
1641
|
+
key = d.toISOString().slice(0, 10)
|
|
1642
|
+
} else if (type === 'weekly') {
|
|
1643
|
+
key = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
1644
|
+
} else {
|
|
1645
|
+
key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
1646
|
+
}
|
|
1647
|
+
if (!key) return
|
|
1648
|
+
periods.add(key)
|
|
1649
|
+
|
|
1650
|
+
const author = c.author || 'unknown'
|
|
1651
|
+
if (!byAuthor.has(author)) byAuthor.set(author, {})
|
|
1652
|
+
const obj = byAuthor.get(author)
|
|
1653
|
+
obj[key] = (obj[key] || 0) + 1
|
|
1654
|
+
})
|
|
1655
|
+
|
|
1656
|
+
const allPeriods = Array.from(periods).sort()
|
|
1657
|
+
const authors = Array.from(byAuthor.keys()).sort()
|
|
1658
|
+
const series = authors.map((a) => ({
|
|
1659
|
+
name: a,
|
|
1660
|
+
type: 'line',
|
|
1661
|
+
smooth: true,
|
|
1662
|
+
data: allPeriods.map((p) => byAuthor.get(a)[p] || 0)
|
|
1663
|
+
}))
|
|
1664
|
+
return { authors, allPeriods, series }
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
function drawAuthorOvertimeTrends(commits, stats) {
|
|
1668
|
+
const el = document.getElementById('chartAuthorOvertime')
|
|
1669
|
+
if (!el) return null
|
|
1670
|
+
const chart = echarts.init(el)
|
|
1671
|
+
|
|
1672
|
+
const startHour =
|
|
1673
|
+
typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
1674
|
+
? stats.startHour
|
|
1675
|
+
: 9
|
|
1676
|
+
const endHour =
|
|
1677
|
+
typeof stats.endHour === 'number' && stats.endHour >= 0
|
|
1678
|
+
? stats.endHour
|
|
1679
|
+
: window.__overtimeEndHour || 18
|
|
1680
|
+
const cutoff = window.__overnightCutoff ?? 6
|
|
1681
|
+
|
|
1682
|
+
function render(type) {
|
|
1683
|
+
const ds = buildAuthorOvertimeDataset(
|
|
1684
|
+
commits,
|
|
1685
|
+
type,
|
|
1686
|
+
startHour,
|
|
1687
|
+
endHour,
|
|
1688
|
+
cutoff
|
|
1689
|
+
)
|
|
1690
|
+
ds.rangeMap = {}
|
|
1691
|
+
|
|
1692
|
+
for (const period of ds.allPeriods) {
|
|
1693
|
+
if (period.includes('-W')) {
|
|
1694
|
+
const [yy, ww] = period.split('-W')
|
|
1695
|
+
ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
chart.setOption({
|
|
1699
|
+
tooltip: {
|
|
1700
|
+
trigger: 'axis',
|
|
1701
|
+
formatter(params) {
|
|
1702
|
+
if (!params || !params.length) return ''
|
|
1703
|
+
|
|
1704
|
+
const p = params[0]
|
|
1705
|
+
const label = p.axisValue
|
|
1706
|
+
const isWeekly = type === 'weekly'
|
|
1707
|
+
|
|
1708
|
+
let extra = ''
|
|
1709
|
+
if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
|
|
1710
|
+
const { start, end } = ds.rangeMap[label]
|
|
1711
|
+
extra = `<div style="margin-top:4px;color:#999;font-size:12px">
|
|
1712
|
+
周区间:${start} ~ ${end}
|
|
1713
|
+
</div>`
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
const lines = params
|
|
1717
|
+
.filter((i) => i.data > 0)
|
|
1718
|
+
.sort((a, b) => (b.data || 0) - (a.data || 0) || String(a.seriesName).localeCompare(String(b.seriesName)))
|
|
1719
|
+
.map(
|
|
1720
|
+
(item) => `${item.marker}${item.seriesName}: ${item.data} 次提交`
|
|
1721
|
+
)
|
|
1722
|
+
.join('<br/>')
|
|
1723
|
+
|
|
1724
|
+
return `
|
|
1725
|
+
<div>${label}</div>
|
|
1726
|
+
${extra}
|
|
1727
|
+
${lines}
|
|
1728
|
+
`
|
|
1729
|
+
}
|
|
1730
|
+
},
|
|
1731
|
+
legend: { data: ds.authors },
|
|
1732
|
+
xAxis: { type: 'category', data: ds.allPeriods },
|
|
1733
|
+
// 把 y 轴名称改为提交数
|
|
1734
|
+
yAxis: { type: 'value', name: '提交数 (次)' },
|
|
1735
|
+
|
|
1736
|
+
series: ds.series
|
|
1737
|
+
})
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// 初始按日
|
|
1741
|
+
render('daily')
|
|
1742
|
+
|
|
1743
|
+
// tabs 切换
|
|
1744
|
+
const tabs = document.querySelectorAll('#tabsOvertime button')
|
|
1745
|
+
tabs.forEach((btnEl) => {
|
|
1746
|
+
btnEl.addEventListener('click', () => {
|
|
1747
|
+
tabs.forEach((b) => b.classList.remove('active'))
|
|
1748
|
+
btnEl.classList.add('active')
|
|
1749
|
+
render(btnEl.dataset.type)
|
|
1750
|
+
})
|
|
1751
|
+
})
|
|
1752
|
+
|
|
1753
|
+
// 输出本周风险与加班时长排行
|
|
1754
|
+
renderWeeklyRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1755
|
+
renderMonthlyRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1756
|
+
// 新增:本周/本月加班时长排名(显示所有作者总时长,前三名带图标,状元标注“夜魔侠”)
|
|
1757
|
+
renderWeeklyDurationRankSummary(commits, { startHour, endHour, cutoff })
|
|
1758
|
+
renderWeeklyDurationRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1759
|
+
renderMonthlyDurationRankSummary(commits, { startHour, endHour, cutoff })
|
|
1760
|
+
renderMonthlyDurationRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1761
|
+
renderRolling30DurationRiskSummary(commits, { startHour, endHour, cutoff })
|
|
1762
|
+
|
|
1763
|
+
// 点击事件:点击某个作者在某周期的点,打开侧栏显示该作者在该周期内的下班后提交(加班)明细
|
|
1764
|
+
chart.on('click', (p) => {
|
|
1765
|
+
try {
|
|
1766
|
+
if (!p || p.componentType !== 'series') return
|
|
1767
|
+
const label = p.axisValue || p.name
|
|
1768
|
+
const author = p.seriesName
|
|
1769
|
+
if (!label || !author) return
|
|
1770
|
+
const type = document.querySelector('#tabsOvertime button.active')?.dataset.type || 'daily'
|
|
1771
|
+
|
|
1772
|
+
const filteredCommits = commits.filter((c) => {
|
|
1773
|
+
const a = c.author || 'unknown'
|
|
1774
|
+
if (a !== author) return false
|
|
1775
|
+
const d = new Date(c.date)
|
|
1776
|
+
if (Number.isNaN(d.valueOf())) return false
|
|
1777
|
+
const h = d.getHours()
|
|
1778
|
+
const isOT = (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
|
|
1779
|
+
if (!isOT) return false
|
|
1780
|
+
|
|
1781
|
+
if (type === 'daily') return d.toISOString().slice(0, 10) === label
|
|
1782
|
+
if (type === 'weekly') {
|
|
1783
|
+
if (!label.includes('-W')) return false
|
|
1784
|
+
const [yy, ww] = label.split('-W')
|
|
1785
|
+
const range = getISOWeekRange(Number(yy), Number(ww))
|
|
1786
|
+
const day = d.toISOString().slice(0, 10)
|
|
1787
|
+
return day >= range.start && day <= range.end
|
|
1788
|
+
}
|
|
1789
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
1790
|
+
return month === label
|
|
1791
|
+
})
|
|
1792
|
+
|
|
1793
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
1794
|
+
|
|
1795
|
+
if (type === 'weekly') {
|
|
1796
|
+
const weeklyItem = { outsideWorkCount: filteredCommits.length, outsideWorkRate: 0 }
|
|
1797
|
+
showSideBarForWeek({ period: label, weeklyItem, commits: filteredCommits, titleDrawer: `${author} 加班本周详情` })
|
|
1798
|
+
} else {
|
|
1799
|
+
showDayDetailSidebar({ date: label, count: filteredCommits.length, commits: filteredCommits, titleDrawer: `${author} 加班 ${type} 详情` })
|
|
1800
|
+
}
|
|
1801
|
+
} catch (err) {
|
|
1802
|
+
console.warn('Overtime chart click handler error', err)
|
|
1803
|
+
}
|
|
1804
|
+
})
|
|
1805
|
+
|
|
1806
|
+
return chart
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function renderWeeklyRiskSummary(
|
|
1810
|
+
commits,
|
|
1811
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
1812
|
+
) {
|
|
1813
|
+
const box = document.getElementById('weeklyRiskSummary')
|
|
1814
|
+
if (!box) return
|
|
1815
|
+
|
|
1816
|
+
// 获取当前周与上一周 key
|
|
1817
|
+
const now = new Date()
|
|
1818
|
+
const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
|
|
1819
|
+
const prev = new Date(now)
|
|
1820
|
+
prev.setDate(prev.getDate() - 7)
|
|
1821
|
+
const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
|
|
1822
|
+
|
|
1823
|
+
// 统计:每周 -> author -> count;同时统计每周日期集合
|
|
1824
|
+
const weekAuthor = new Map()
|
|
1825
|
+
const weekDatesByAuthor = new Map() // week -> author -> Set(date)
|
|
1826
|
+
|
|
1827
|
+
commits.forEach((c) => {
|
|
1828
|
+
const d = new Date(c.date)
|
|
1829
|
+
if (Number.isNaN(d.valueOf())) return
|
|
1830
|
+
const h = d.getHours()
|
|
1831
|
+
const isOT =
|
|
1832
|
+
(h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
|
|
1833
|
+
if (!isOT) return
|
|
1834
|
+
|
|
1835
|
+
const key = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
1836
|
+
if (!key) return
|
|
1837
|
+
const author = c.author || 'unknown'
|
|
1838
|
+
|
|
1839
|
+
if (!weekAuthor.has(key)) weekAuthor.set(key, new Map())
|
|
1840
|
+
const m = weekAuthor.get(key)
|
|
1841
|
+
m.set(author, (m.get(author) || 0) + 1)
|
|
1842
|
+
|
|
1843
|
+
if (!weekDatesByAuthor.has(key)) weekDatesByAuthor.set(key, new Map())
|
|
1844
|
+
const dMap = weekDatesByAuthor.get(key)
|
|
1845
|
+
if (!dMap.has(author)) dMap.set(author, new Set())
|
|
1846
|
+
dMap.get(author).add(d.toISOString().slice(0, 10))
|
|
1847
|
+
})
|
|
1848
|
+
|
|
1849
|
+
const curMap = weekAuthor.get(curKey) || new Map()
|
|
1850
|
+
const prevMap = weekAuthor.get(prevKey) || new Map()
|
|
1851
|
+
const curTotal = Array.from(curMap.values()).reduce((a, b) => a + b, 0)
|
|
1852
|
+
const prevTotal = Array.from(prevMap.values()).reduce((a, b) => a + b, 0)
|
|
1853
|
+
const delta =
|
|
1854
|
+
prevTotal > 0
|
|
1855
|
+
? Math.round(((curTotal - prevTotal) / prevTotal) * 100)
|
|
1856
|
+
: null
|
|
1857
|
+
|
|
1858
|
+
// 找当前周最“活跃”的人(加班提交最多),并统计他加班的自然日数
|
|
1859
|
+
let topAuthor = null
|
|
1860
|
+
let topCount = -1
|
|
1861
|
+
curMap.forEach((v, k) => {
|
|
1862
|
+
if (v > topCount) {
|
|
1863
|
+
topCount = v
|
|
1864
|
+
topAuthor = k
|
|
1865
|
+
}
|
|
1866
|
+
})
|
|
1867
|
+
const curDatesMap = weekDatesByAuthor.get(curKey) || new Map()
|
|
1868
|
+
const topDays =
|
|
1869
|
+
topAuthor && curDatesMap.get(topAuthor)
|
|
1870
|
+
? curDatesMap.get(topAuthor).size
|
|
1871
|
+
: 0
|
|
1872
|
+
|
|
1873
|
+
// 文案
|
|
1874
|
+
const lines = []
|
|
1875
|
+
lines.push('【本周风险总结】')
|
|
1876
|
+
|
|
1877
|
+
if (curTotal === 0) {
|
|
1878
|
+
lines.push('团队本周暂无加班提交。')
|
|
1879
|
+
} else if (delta === null) {
|
|
1880
|
+
lines.push(`团队本周加班提交 ${curTotal} 次。`)
|
|
1881
|
+
} else {
|
|
1882
|
+
const trend = delta >= 0 ? '上升' : '下降'
|
|
1883
|
+
lines.push(`团队加班${trend} ${Math.abs(delta)}%(vs 上周)。`)
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
if (topAuthor && curTotal > 0) {
|
|
1887
|
+
const pct = Math.round((topCount / curTotal) * 100)
|
|
1888
|
+
lines.push(
|
|
1889
|
+
`${topAuthor} 夜间活跃度 ${pct}%,${topDays} 天出现下班后提交(${endHour}:00 后或次日 ${cutoff}:00 前)。`
|
|
1890
|
+
)
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
box.innerHTML = `
|
|
1894
|
+
<div class="risk-summary">
|
|
1895
|
+
<div class="risk-title">【本周风险总结】</div>
|
|
1896
|
+
<ul>
|
|
1897
|
+
${lines
|
|
1898
|
+
.slice(1)
|
|
1899
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
1900
|
+
.join('')}
|
|
1901
|
+
</ul>
|
|
1902
|
+
</div>
|
|
1903
|
+
`
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff) {
|
|
1907
|
+
const byAuthorDay = new Map()
|
|
1908
|
+
commits.forEach((c) => {
|
|
1909
|
+
const d = new Date(c.date)
|
|
1910
|
+
if (Number.isNaN(d.valueOf())) return
|
|
1911
|
+
const h = d.getHours()
|
|
1912
|
+
let overtime = null
|
|
1913
|
+
let dayKey = null
|
|
1914
|
+
if (h >= endHour && h < 24) {
|
|
1915
|
+
overtime = h - endHour
|
|
1916
|
+
dayKey = d.toISOString().slice(0, 10)
|
|
1917
|
+
} else if (h >= 0 && h < cutoff && h < startHour) {
|
|
1918
|
+
overtime = 24 - endHour + h
|
|
1919
|
+
const cur = new Date(
|
|
1920
|
+
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
|
|
1921
|
+
)
|
|
1922
|
+
cur.setUTCDate(cur.getUTCDate() - 1)
|
|
1923
|
+
dayKey = cur.toISOString().slice(0, 10)
|
|
1924
|
+
}
|
|
1925
|
+
if (overtime == null || !dayKey) return
|
|
1926
|
+
const author = c.author || 'unknown'
|
|
1927
|
+
if (!byAuthorDay.has(author)) byAuthorDay.set(author, new Map())
|
|
1928
|
+
const m = byAuthorDay.get(author)
|
|
1929
|
+
const cur = m.get(dayKey)
|
|
1930
|
+
if (!cur || overtime > cur) m.set(dayKey, overtime)
|
|
1931
|
+
})
|
|
1932
|
+
return byAuthorDay
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function renderWeeklyDurationRankSummary(commits, { startHour = 9, endHour = 18, cutoff = 6 } = {}) {
|
|
1936
|
+
const box = document.getElementById('weeklyDurationRankSummary')
|
|
1937
|
+
if (!box) return
|
|
1938
|
+
const now = new Date()
|
|
1939
|
+
const curWeek = getIsoWeekKey(now.toISOString().slice(0, 10))
|
|
1940
|
+
const byAuthorDay = computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff)
|
|
1941
|
+
const ranks = []
|
|
1942
|
+
byAuthorDay.forEach((dayMap, author) => {
|
|
1943
|
+
let total = 0
|
|
1944
|
+
dayMap.forEach((v, dayKey) => {
|
|
1945
|
+
const wk = getIsoWeekKey(dayKey)
|
|
1946
|
+
if (wk === curWeek) total += v
|
|
1947
|
+
})
|
|
1948
|
+
if (total > 0) ranks.push({ author, total })
|
|
1949
|
+
})
|
|
1950
|
+
ranks.sort((a, b) => b.total - a.total || String(a.author).localeCompare(String(b.author)))
|
|
1951
|
+
|
|
1952
|
+
const lines = []
|
|
1953
|
+
lines.push('【本周加班时长排名】')
|
|
1954
|
+
if (ranks.length === 0) {
|
|
1955
|
+
lines.push('本周暂无加班时长。')
|
|
1956
|
+
} else {
|
|
1957
|
+
ranks.forEach((r, idx) => {
|
|
1958
|
+
const rank = idx + 1
|
|
1959
|
+
const medal = rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
|
|
1960
|
+
const title = rank === 1 ? '(状元・夜魔侠)' : ''
|
|
1961
|
+
lines.push(`${rank}. ${medal}${r.author} — ${r.total.toFixed(2)} 小时${title}`)
|
|
1962
|
+
})
|
|
1963
|
+
}
|
|
1964
|
+
box.innerHTML = `
|
|
1965
|
+
<div class="risk-summary">
|
|
1966
|
+
<div class="risk-title">【本周加班时长排名】</div>
|
|
1967
|
+
<ul>
|
|
1968
|
+
${lines
|
|
1969
|
+
.slice(1)
|
|
1970
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
1971
|
+
.join('')}
|
|
1972
|
+
</ul>
|
|
1973
|
+
</div>
|
|
1974
|
+
`
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
function renderWeeklyDurationRiskSummary(
|
|
1978
|
+
commits,
|
|
1979
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
1980
|
+
) {
|
|
1981
|
+
const box = document.getElementById('weeklyDurationRiskSummary')
|
|
1982
|
+
if (!box) return
|
|
1983
|
+
const now = new Date()
|
|
1984
|
+
const curWeek = getIsoWeekKey(now.toISOString().slice(0, 10))
|
|
1985
|
+
const byAuthorDay = computeAuthorDailyMaxOvertime(
|
|
1986
|
+
commits,
|
|
1987
|
+
startHour,
|
|
1988
|
+
endHour,
|
|
1989
|
+
cutoff
|
|
1990
|
+
)
|
|
1991
|
+
const sums = []
|
|
1992
|
+
byAuthorDay.forEach((dayMap, author) => {
|
|
1993
|
+
let total = 0
|
|
1994
|
+
dayMap.forEach((v, dayKey) => {
|
|
1995
|
+
const wk = getIsoWeekKey(dayKey)
|
|
1996
|
+
if (wk === curWeek) total += v
|
|
1997
|
+
})
|
|
1998
|
+
if (total > 0) sums.push({ author, total })
|
|
1999
|
+
})
|
|
2000
|
+
sums.sort((a, b) => b.total - a.total)
|
|
2001
|
+
const top = sums.slice(0, 6)
|
|
2002
|
+
const lines = []
|
|
2003
|
+
lines.push('【本周加班时长风险】')
|
|
2004
|
+
if (top.length === 0) {
|
|
2005
|
+
lines.push('本周暂无加班时长风险。')
|
|
2006
|
+
} else {
|
|
2007
|
+
top.forEach(({ author, total }) => {
|
|
2008
|
+
let level = '轻度'
|
|
2009
|
+
if (total >= 12) level = '严重'
|
|
2010
|
+
else if (total >= 6) level = '中度'
|
|
2011
|
+
lines.push(
|
|
2012
|
+
`${author} 本周累计加班 ${total.toFixed(2)} 小时(${level})。`
|
|
2013
|
+
)
|
|
2014
|
+
})
|
|
2015
|
+
}
|
|
2016
|
+
box.innerHTML = `
|
|
2017
|
+
<div class="risk-summary">
|
|
2018
|
+
<div class="risk-title">【本周加班时长风险】</div>
|
|
2019
|
+
<ul>
|
|
2020
|
+
${lines
|
|
2021
|
+
.slice(1)
|
|
2022
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2023
|
+
.join('')}
|
|
2024
|
+
</ul>
|
|
2025
|
+
</div>
|
|
2026
|
+
`
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
function renderMonthlyDurationRankSummary(commits, { startHour = 9, endHour = 18, cutoff = 6 } = {}) {
|
|
2030
|
+
const box = document.getElementById('monthlyDurationRankSummary')
|
|
2031
|
+
if (!box) return
|
|
2032
|
+
const now = new Date()
|
|
2033
|
+
const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
2034
|
+
const byAuthorDay = computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff)
|
|
2035
|
+
const ranks = []
|
|
2036
|
+
byAuthorDay.forEach((dayMap, author) => {
|
|
2037
|
+
let total = 0
|
|
2038
|
+
dayMap.forEach((v, dayKey) => {
|
|
2039
|
+
const m = dayKey.slice(0, 7)
|
|
2040
|
+
if (m === curMonth) total += v
|
|
2041
|
+
})
|
|
2042
|
+
if (total > 0) ranks.push({ author, total })
|
|
2043
|
+
})
|
|
2044
|
+
ranks.sort((a, b) => b.total - a.total || String(a.author).localeCompare(String(b.author)))
|
|
2045
|
+
|
|
2046
|
+
const lines = []
|
|
2047
|
+
lines.push('【本月加班时长排名】')
|
|
2048
|
+
if (ranks.length === 0) {
|
|
2049
|
+
lines.push('本月暂无加班时长。')
|
|
2050
|
+
} else {
|
|
2051
|
+
ranks.forEach((r, idx) => {
|
|
2052
|
+
const rank = idx + 1
|
|
2053
|
+
const medal = rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
|
|
2054
|
+
const title = rank === 1 ? '(状元・夜魔侠)' : ''
|
|
2055
|
+
lines.push(`${rank}. ${medal}${r.author} — ${r.total.toFixed(2)} 小时${title}`)
|
|
2056
|
+
})
|
|
2057
|
+
}
|
|
2058
|
+
box.innerHTML = `
|
|
2059
|
+
<div class="risk-summary">
|
|
2060
|
+
<div class="risk-title">【本月加班时长排名】</div>
|
|
2061
|
+
<ul>
|
|
2062
|
+
${lines
|
|
2063
|
+
.slice(1)
|
|
2064
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2065
|
+
.join('')}
|
|
2066
|
+
</ul>
|
|
2067
|
+
</div>
|
|
2068
|
+
`
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function renderMonthlyDurationRiskSummary(
|
|
2072
|
+
commits,
|
|
2073
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
2074
|
+
) {
|
|
2075
|
+
const box = document.getElementById('monthlyDurationRiskSummary')
|
|
2076
|
+
if (!box) return
|
|
2077
|
+
const now = new Date()
|
|
2078
|
+
const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
2079
|
+
const byAuthorDay = computeAuthorDailyMaxOvertime(
|
|
2080
|
+
commits,
|
|
2081
|
+
startHour,
|
|
2082
|
+
endHour,
|
|
2083
|
+
cutoff
|
|
2084
|
+
)
|
|
2085
|
+
const sums = []
|
|
2086
|
+
byAuthorDay.forEach((dayMap, author) => {
|
|
2087
|
+
let total = 0
|
|
2088
|
+
dayMap.forEach((v, dayKey) => {
|
|
2089
|
+
const m = dayKey.slice(0, 7)
|
|
2090
|
+
if (m === curMonth) total += v
|
|
2091
|
+
})
|
|
2092
|
+
if (total > 0) sums.push({ author, total })
|
|
2093
|
+
})
|
|
2094
|
+
sums.sort((a, b) => b.total - a.total)
|
|
2095
|
+
const top = sums.slice(0, 6)
|
|
2096
|
+
const lines = []
|
|
2097
|
+
lines.push('【本月加班时长风险】')
|
|
2098
|
+
if (top.length === 0) {
|
|
2099
|
+
lines.push('本月暂无加班时长风险。')
|
|
2100
|
+
} else {
|
|
2101
|
+
top.forEach(({ author, total }) => {
|
|
2102
|
+
let level = '轻度'
|
|
2103
|
+
if (total >= 20) level = '严重'
|
|
2104
|
+
else if (total >= 10) level = '中度'
|
|
2105
|
+
lines.push(
|
|
2106
|
+
`${author} 本月累计加班 ${total.toFixed(2)} 小时(${level})。`
|
|
2107
|
+
)
|
|
2108
|
+
})
|
|
2109
|
+
}
|
|
2110
|
+
box.innerHTML = `
|
|
2111
|
+
<div class="risk-summary">
|
|
2112
|
+
<div class="risk-title">【本月加班时长风险】</div>
|
|
2113
|
+
<ul>
|
|
2114
|
+
${lines
|
|
2115
|
+
.slice(1)
|
|
2116
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2117
|
+
.join('')}
|
|
2118
|
+
</ul>
|
|
2119
|
+
</div>
|
|
2120
|
+
`
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function renderRolling30DurationRiskSummary(
|
|
2124
|
+
commits,
|
|
2125
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
2126
|
+
) {
|
|
2127
|
+
const box = document.getElementById('rolling30DurationRiskSummary')
|
|
2128
|
+
if (!box) return
|
|
2129
|
+
const now = new Date()
|
|
2130
|
+
const utcToday = new Date(
|
|
2131
|
+
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
|
2132
|
+
)
|
|
2133
|
+
utcToday.setUTCDate(utcToday.getUTCDate() - 29)
|
|
2134
|
+
const startKey = utcToday.toISOString().slice(0, 10)
|
|
2135
|
+
|
|
2136
|
+
const byAuthorDay = computeAuthorDailyMaxOvertime(
|
|
2137
|
+
commits,
|
|
2138
|
+
startHour,
|
|
2139
|
+
endHour,
|
|
2140
|
+
cutoff
|
|
2141
|
+
)
|
|
2142
|
+
const sums = []
|
|
2143
|
+
byAuthorDay.forEach((dayMap, author) => {
|
|
2144
|
+
let total = 0
|
|
2145
|
+
dayMap.forEach((v, dayKey) => {
|
|
2146
|
+
if (dayKey >= startKey) total += v
|
|
2147
|
+
})
|
|
2148
|
+
if (total > 0) sums.push({ author, total })
|
|
2149
|
+
})
|
|
2150
|
+
sums.sort((a, b) => b.total - a.total)
|
|
2151
|
+
const top = sums.slice(0, 6)
|
|
2152
|
+
const lines = []
|
|
2153
|
+
lines.push('【最近30天加班时长风险】')
|
|
2154
|
+
if (top.length === 0) {
|
|
2155
|
+
lines.push('最近30天暂无加班时长风险。')
|
|
2156
|
+
} else {
|
|
2157
|
+
top.forEach(({ author, total }) => {
|
|
2158
|
+
let level = '轻度'
|
|
2159
|
+
if (total >= 20) level = '严重'
|
|
2160
|
+
else if (total >= 10) level = '中度'
|
|
2161
|
+
lines.push(
|
|
2162
|
+
`${author} 最近30天累计加班 ${total.toFixed(2)} 小时(${level})。`
|
|
2163
|
+
)
|
|
2164
|
+
})
|
|
2165
|
+
}
|
|
2166
|
+
box.innerHTML = `
|
|
2167
|
+
<div class="risk-summary">
|
|
2168
|
+
<div class="risk-title">【最近30天加班时长风险】</div>
|
|
2169
|
+
<ul>
|
|
2170
|
+
${lines
|
|
2171
|
+
.slice(1)
|
|
2172
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2173
|
+
.join('')}
|
|
2174
|
+
</ul>
|
|
2175
|
+
</div>
|
|
2176
|
+
`
|
|
2177
|
+
}
|
|
2178
|
+
function renderMonthlyRiskSummary(
|
|
2179
|
+
commits,
|
|
2180
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
2181
|
+
) {
|
|
2182
|
+
const box = document.getElementById('monthlyRiskSummary')
|
|
2183
|
+
if (!box) return
|
|
2184
|
+
|
|
2185
|
+
const now = new Date()
|
|
2186
|
+
const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
2187
|
+
const prev = new Date(now)
|
|
2188
|
+
prev.setMonth(prev.getMonth() - 1)
|
|
2189
|
+
const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
|
|
2190
|
+
|
|
2191
|
+
const monthAuthor = new Map()
|
|
2192
|
+
const monthMax = new Map()
|
|
2193
|
+
|
|
2194
|
+
commits.forEach((c) => {
|
|
2195
|
+
const d = new Date(c.date)
|
|
2196
|
+
if (Number.isNaN(d.valueOf())) return
|
|
2197
|
+
const h = d.getHours()
|
|
2198
|
+
const isOT =
|
|
2199
|
+
(h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
|
|
2200
|
+
if (!isOT) return
|
|
2201
|
+
|
|
2202
|
+
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
2203
|
+
const author = c.author || 'unknown'
|
|
2204
|
+
|
|
2205
|
+
if (!monthAuthor.has(key)) monthAuthor.set(key, new Map())
|
|
2206
|
+
const m = monthAuthor.get(key)
|
|
2207
|
+
m.set(author, (m.get(author) || 0) + 1)
|
|
2208
|
+
|
|
2209
|
+
let overtime = null
|
|
2210
|
+
if (h >= endHour && h < 24) overtime = h - endHour
|
|
2211
|
+
else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
|
|
2212
|
+
if (overtime == null) return
|
|
2213
|
+
|
|
2214
|
+
if (!monthMax.has(key)) monthMax.set(key, new Map())
|
|
2215
|
+
const mm = monthMax.get(key)
|
|
2216
|
+
const cur = mm.get(author)
|
|
2217
|
+
const dateStr = d.toISOString().slice(0, 10)
|
|
2218
|
+
if (!cur || overtime > cur.max)
|
|
2219
|
+
mm.set(author, { max: overtime, date: dateStr })
|
|
2220
|
+
})
|
|
2221
|
+
|
|
2222
|
+
const curMap = monthAuthor.get(curKey) || new Map()
|
|
2223
|
+
const prevMap = monthAuthor.get(prevKey) || new Map()
|
|
2224
|
+
const curTotal = Array.from(curMap.values()).reduce((a, b) => a + b, 0)
|
|
2225
|
+
const prevTotal = Array.from(prevMap.values()).reduce((a, b) => a + b, 0)
|
|
2226
|
+
const delta =
|
|
2227
|
+
prevTotal > 0
|
|
2228
|
+
? Math.round(((curTotal - prevTotal) / prevTotal) * 100)
|
|
2229
|
+
: null
|
|
2230
|
+
|
|
2231
|
+
let topAuthor = null
|
|
2232
|
+
let top = { max: -1, date: null }
|
|
2233
|
+
const curMaxMap = monthMax.get(curKey) || new Map()
|
|
2234
|
+
curMaxMap.forEach((v, k) => {
|
|
2235
|
+
if (v.max > top.max) {
|
|
2236
|
+
top = v
|
|
2237
|
+
topAuthor = k
|
|
2238
|
+
}
|
|
2239
|
+
})
|
|
2240
|
+
|
|
2241
|
+
let prevMax = -1
|
|
2242
|
+
const prevMaxMap = monthMax.get(prevKey) || new Map()
|
|
2243
|
+
prevMaxMap.forEach((v) => {
|
|
2244
|
+
if (v.max > prevMax) prevMax = v.max
|
|
2245
|
+
})
|
|
2246
|
+
|
|
2247
|
+
const lines = []
|
|
2248
|
+
lines.push('【本月加班风险】')
|
|
2249
|
+
|
|
2250
|
+
if (curTotal === 0) {
|
|
2251
|
+
lines.push('本月尚无下班后提交,未发现明显风险。')
|
|
2252
|
+
} else {
|
|
2253
|
+
if (delta === null) {
|
|
2254
|
+
lines.push(`本月下班后提交 ${curTotal} 次。`)
|
|
2255
|
+
} else {
|
|
2256
|
+
const trend = delta >= 0 ? '上升' : '下降'
|
|
2257
|
+
lines.push(`本月下班后提交${trend} ${Math.abs(delta)}%(vs 上月)。`)
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
if (top.max >= 0) {
|
|
2261
|
+
let trend2 = '暂无上月对比'
|
|
2262
|
+
if (prevMax >= 0) {
|
|
2263
|
+
if (top.max > prevMax) trend2 = '较上月更晚'
|
|
2264
|
+
else if (top.max < prevMax) trend2 = '较上月提前'
|
|
2265
|
+
else trend2 = '与上月持平'
|
|
2266
|
+
}
|
|
2267
|
+
lines.push(
|
|
2268
|
+
`${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend2}。`
|
|
2269
|
+
)
|
|
2270
|
+
if (top.max >= 2) lines.push('已超过 2 小时,存在严重加班风险。')
|
|
2271
|
+
else if (top.max >= 1) lines.push('已超过 1 小时,存在中度加班风险。')
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
box.innerHTML = `
|
|
2276
|
+
<div class="risk-summary">
|
|
2277
|
+
<div class="risk-title">【本月加班风险】</div>
|
|
2278
|
+
<ul>
|
|
2279
|
+
${lines
|
|
2280
|
+
.slice(1)
|
|
2281
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2282
|
+
.join('')}
|
|
2283
|
+
</ul>
|
|
2284
|
+
</div>
|
|
2285
|
+
`
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// ========= 开发者加班“最晚”趋势(每期取最大超时) =========
|
|
2289
|
+
function buildAuthorLatestDataset(commits, type, startHour, endHour, cutoff) {
|
|
2290
|
+
const byAuthor = new Map()
|
|
2291
|
+
const periods = new Set()
|
|
2292
|
+
|
|
2293
|
+
commits.forEach((c) => {
|
|
2294
|
+
const d = new Date(c.date)
|
|
2295
|
+
if (Number.isNaN(d.valueOf())) return
|
|
2296
|
+
const h = d.getHours()
|
|
2297
|
+
|
|
2298
|
+
let overtime = null
|
|
2299
|
+
if (h >= endHour && h < 24) overtime = h - endHour
|
|
2300
|
+
else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
|
|
2301
|
+
if (overtime == null) return
|
|
2302
|
+
|
|
2303
|
+
let key
|
|
2304
|
+
if (type === 'daily') {
|
|
2305
|
+
key = d.toISOString().slice(0, 10)
|
|
2306
|
+
} else if (type === 'weekly') {
|
|
2307
|
+
key = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
2308
|
+
} else {
|
|
2309
|
+
key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
2310
|
+
}
|
|
2311
|
+
if (!key) return
|
|
2312
|
+
periods.add(key)
|
|
2313
|
+
|
|
2314
|
+
const author = c.author || 'unknown'
|
|
2315
|
+
if (!byAuthor.has(author)) byAuthor.set(author, {})
|
|
2316
|
+
const obj = byAuthor.get(author)
|
|
2317
|
+
obj[key] = Math.max(obj[key] || 0, overtime)
|
|
2318
|
+
})
|
|
2319
|
+
|
|
2320
|
+
const allPeriods = Array.from(periods).sort()
|
|
2321
|
+
|
|
2322
|
+
const authors = Array.from(byAuthor.keys()).sort()
|
|
2323
|
+
const series = authors.map((a) => ({
|
|
2324
|
+
name: a,
|
|
2325
|
+
type: 'line',
|
|
2326
|
+
smooth: true,
|
|
2327
|
+
data: allPeriods.map((p) => byAuthor.get(a)[p] || 0)
|
|
2328
|
+
}))
|
|
2329
|
+
return { authors, allPeriods, series }
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
function drawAuthorLatestOvertimeTrends(commits, stats) {
|
|
2333
|
+
const el = document.getElementById('chartAuthorLatestOvertime')
|
|
2334
|
+
if (!el) return null
|
|
2335
|
+
const chart = echarts.init(el)
|
|
2336
|
+
|
|
2337
|
+
const startHour =
|
|
2338
|
+
typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
2339
|
+
? stats.startHour
|
|
2340
|
+
: 9
|
|
2341
|
+
const endHour =
|
|
2342
|
+
typeof stats.endHour === 'number' && stats.endHour >= 0
|
|
2343
|
+
? stats.endHour
|
|
2344
|
+
: window.__overtimeEndHour || 18
|
|
2345
|
+
const cutoff = window.__overnightCutoff ?? 6
|
|
2346
|
+
|
|
2347
|
+
function render(type) {
|
|
2348
|
+
const ds = buildAuthorLatestDataset(
|
|
2349
|
+
commits,
|
|
2350
|
+
type,
|
|
2351
|
+
startHour,
|
|
2352
|
+
endHour,
|
|
2353
|
+
cutoff
|
|
2354
|
+
)
|
|
2355
|
+
ds.rangeMap = {}
|
|
2356
|
+
|
|
2357
|
+
for (const period of ds.allPeriods) {
|
|
2358
|
+
if (period.includes('-W')) {
|
|
2359
|
+
const [yy, ww] = period.split('-W')
|
|
2360
|
+
ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
chart.setOption({
|
|
2364
|
+
tooltip: {
|
|
2365
|
+
trigger: 'axis',
|
|
2366
|
+
formatter(params) {
|
|
2367
|
+
if (!params || !params.length) return ''
|
|
2368
|
+
|
|
2369
|
+
const p = params[0]
|
|
2370
|
+
const label = p.axisValue
|
|
2371
|
+
const isWeekly = type === 'weekly'
|
|
2372
|
+
|
|
2373
|
+
let extra = ''
|
|
2374
|
+
if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
|
|
2375
|
+
const { start, end } = ds.rangeMap[label]
|
|
2376
|
+
extra = `<div style="margin-top:4px;color:#999;font-size:12px">
|
|
2377
|
+
周区间:${start} ~ ${end}
|
|
2378
|
+
</div>`
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
const lines = params
|
|
2382
|
+
.filter((i) => i.data > 0)
|
|
2383
|
+
.sort((a, b) => (b.data || 0) - (a.data || 0) || String(a.seriesName).localeCompare(String(b.seriesName)))
|
|
2384
|
+
.map(
|
|
2385
|
+
(item) => `${item.marker}${item.seriesName}: ${item.data} 小时`
|
|
2386
|
+
)
|
|
2387
|
+
.join('<br/>')
|
|
2388
|
+
|
|
2389
|
+
return `
|
|
2390
|
+
<div>${label}</div>
|
|
2391
|
+
${extra}
|
|
2392
|
+
${lines}
|
|
2393
|
+
`
|
|
2394
|
+
}
|
|
2395
|
+
},
|
|
2396
|
+
legend: { data: ds.authors },
|
|
2397
|
+
xAxis: { type: 'category', data: ds.allPeriods },
|
|
2398
|
+
yAxis: {
|
|
2399
|
+
type: 'value',
|
|
2400
|
+
name: '超出下班(小时)',
|
|
2401
|
+
min: 0
|
|
2402
|
+
},
|
|
2403
|
+
series: ds.series
|
|
2404
|
+
})
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
render('daily')
|
|
2408
|
+
|
|
2409
|
+
const tabs = document.querySelectorAll('#tabsLatestOvertime button')
|
|
2410
|
+
tabs.forEach((btnEl) => {
|
|
2411
|
+
btnEl.addEventListener('click', () => {
|
|
2412
|
+
tabs.forEach((b) => b.classList.remove('active'))
|
|
2413
|
+
btnEl.classList.add('active')
|
|
2414
|
+
render(btnEl.dataset.type)
|
|
2415
|
+
})
|
|
2416
|
+
})
|
|
2417
|
+
|
|
2418
|
+
renderLatestRiskSummary(commits, { startHour, endHour, cutoff })
|
|
2419
|
+
renderLatestMonthlyRiskSummary(commits, { startHour, endHour, cutoff })
|
|
2420
|
+
|
|
2421
|
+
// 点击事件:点击某个作者在某周期的点,打开侧栏显示该作者在该周期内的加班提交明细(用于查看具体提交与时间)
|
|
2422
|
+
chart.on('click', (p) => {
|
|
2423
|
+
try {
|
|
2424
|
+
if (!p || p.componentType !== 'series') return
|
|
2425
|
+
const label = p.axisValue || p.name
|
|
2426
|
+
const author = p.seriesName
|
|
2427
|
+
if (!label || !author) return
|
|
2428
|
+
const type = document.querySelector('#tabsLatestOvertime button.active')?.dataset.type || 'daily'
|
|
2429
|
+
|
|
2430
|
+
const filteredCommits = commits.filter((c) => {
|
|
2431
|
+
const a = c.author || 'unknown'
|
|
2432
|
+
if (a !== author) return false
|
|
2433
|
+
const d = new Date(c.date)
|
|
2434
|
+
if (Number.isNaN(d.valueOf())) return false
|
|
2435
|
+
const h = d.getHours()
|
|
2436
|
+
let overtime = null
|
|
2437
|
+
if (h >= endHour && h < 24) overtime = h - endHour
|
|
2438
|
+
else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
|
|
2439
|
+
if (overtime == null) return false
|
|
2440
|
+
|
|
2441
|
+
if (type === 'daily') return d.toISOString().slice(0, 10) === label
|
|
2442
|
+
if (type === 'weekly') {
|
|
2443
|
+
if (!label.includes('-W')) return false
|
|
2444
|
+
const [yy, ww] = label.split('-W')
|
|
2445
|
+
const range = getISOWeekRange(Number(yy), Number(ww))
|
|
2446
|
+
const day = d.toISOString().slice(0, 10)
|
|
2447
|
+
return day >= range.start && day <= range.end
|
|
2448
|
+
}
|
|
2449
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
2450
|
+
return month === label
|
|
2451
|
+
})
|
|
2452
|
+
|
|
2453
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
2454
|
+
|
|
2455
|
+
if (type === 'weekly') {
|
|
2456
|
+
const weeklyItem = { outsideWorkCount: filteredCommits.length, outsideWorkRate: 0 }
|
|
2457
|
+
showSideBarForWeek({ period: label, weeklyItem, commits: filteredCommits, titleDrawer: `${author} 本周最晚加班详情` })
|
|
2458
|
+
} else {
|
|
2459
|
+
showDayDetailSidebar({ date: label, count: filteredCommits.length, commits: filteredCommits, titleDrawer: `${author} 本日最晚加班详情` })
|
|
2460
|
+
}
|
|
2461
|
+
} catch (err) {
|
|
2462
|
+
console.warn('Latest overtime chart click handler error', err)
|
|
2463
|
+
}
|
|
2464
|
+
})
|
|
2465
|
+
|
|
2466
|
+
return chart
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
// 本周“最晚加班”风险提示
|
|
2470
|
+
function renderLatestRiskSummary(
|
|
2471
|
+
commits,
|
|
2472
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
2473
|
+
) {
|
|
2474
|
+
const box = document.getElementById('latestRiskSummary')
|
|
2475
|
+
if (!box) return
|
|
2476
|
+
|
|
2477
|
+
const now = new Date()
|
|
2478
|
+
const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
|
|
2479
|
+
const prev = new Date(now)
|
|
2480
|
+
prev.setDate(prev.getDate() - 7)
|
|
2481
|
+
const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
|
|
2482
|
+
|
|
2483
|
+
// 统计每周每人最大超时
|
|
2484
|
+
const weekMax = new Map() // week -> Map(author -> {max, date})
|
|
2485
|
+
commits.forEach((c) => {
|
|
2486
|
+
const d = new Date(c.date)
|
|
2487
|
+
if (Number.isNaN(d.valueOf())) return
|
|
2488
|
+
const h = d.getHours()
|
|
2489
|
+
let overtime = null
|
|
2490
|
+
if (h >= endHour && h < 24) overtime = h - endHour
|
|
2491
|
+
else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
|
|
2492
|
+
if (overtime == null) return
|
|
2493
|
+
|
|
2494
|
+
const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
2495
|
+
if (!wKey) return
|
|
2496
|
+
if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
|
|
2497
|
+
const m = weekMax.get(wKey)
|
|
2498
|
+
const author = c.author || 'unknown'
|
|
2499
|
+
const cur = m.get(author)
|
|
2500
|
+
if (!cur || overtime > cur.max) {
|
|
2501
|
+
m.set(author, { max: overtime, date: d.toISOString().slice(0, 10) })
|
|
2502
|
+
}
|
|
2503
|
+
})
|
|
2504
|
+
|
|
2505
|
+
const curMap = weekMax.get(curKey) || new Map()
|
|
2506
|
+
const prevMap = weekMax.get(prevKey) || new Map()
|
|
2507
|
+
|
|
2508
|
+
// 当前周的全局最晚
|
|
2509
|
+
let topAuthor = null
|
|
2510
|
+
let top = { max: -1, date: null }
|
|
2511
|
+
curMap.forEach((v, k) => {
|
|
2512
|
+
if (v.max > top.max) {
|
|
2513
|
+
top = v
|
|
2514
|
+
topAuthor = k
|
|
2515
|
+
}
|
|
2516
|
+
})
|
|
2517
|
+
|
|
2518
|
+
// 上周全局最晚,用于趋势判断
|
|
2519
|
+
let prevMax = -1
|
|
2520
|
+
prevMap.forEach((v) => {
|
|
2521
|
+
if (v.max > prevMax) prevMax = v.max
|
|
2522
|
+
})
|
|
2523
|
+
|
|
2524
|
+
const lines = []
|
|
2525
|
+
lines.push('【本周最晚加班风险】')
|
|
2526
|
+
|
|
2527
|
+
if (top.max < 0) {
|
|
2528
|
+
lines.push('本周尚无下班后/凌晨提交,未发现明显风险。')
|
|
2529
|
+
} else {
|
|
2530
|
+
let trend = '暂无上周对比'
|
|
2531
|
+
if (prevMax >= 0) {
|
|
2532
|
+
if (top.max > prevMax) trend = '较上周更晚'
|
|
2533
|
+
else if (top.max < prevMax) trend = '较上周提前'
|
|
2534
|
+
else trend = '与上周持平'
|
|
2535
|
+
}
|
|
2536
|
+
lines.push(
|
|
2537
|
+
`${topAuthor} 本周最晚超出下班 ${top.max.toFixed(
|
|
2538
|
+
2
|
|
2539
|
+
)} 小时(${top.date}),${trend}。`
|
|
2540
|
+
)
|
|
2541
|
+
if (top.max >= 2) {
|
|
2542
|
+
lines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
|
|
2543
|
+
} else if (top.max >= 1) {
|
|
2544
|
+
lines.push('已超过 1 小时,注意控制夜间工作时长。')
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
box.innerHTML = `
|
|
2549
|
+
<div class="risk-summary">
|
|
2550
|
+
<div class="risk-title">【本周最晚加班风险】</div>
|
|
2551
|
+
<ul>
|
|
2552
|
+
${lines
|
|
2553
|
+
.slice(1)
|
|
2554
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2555
|
+
.join('')}
|
|
2556
|
+
</ul>
|
|
2557
|
+
</div>
|
|
2558
|
+
`
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
function renderLatestMonthlyRiskSummary(
|
|
2562
|
+
commits,
|
|
2563
|
+
{ startHour = 9, endHour = 18, cutoff = 6 } = {}
|
|
2564
|
+
) {
|
|
2565
|
+
const box = document.getElementById('latestMonthlyRiskSummary')
|
|
2566
|
+
if (!box) return
|
|
2567
|
+
|
|
2568
|
+
const now = new Date()
|
|
2569
|
+
const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
2570
|
+
const prev = new Date(now)
|
|
2571
|
+
prev.setMonth(prev.getMonth() - 1)
|
|
2572
|
+
const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
|
|
2573
|
+
|
|
2574
|
+
const monthMax = new Map()
|
|
2575
|
+
commits.forEach((c) => {
|
|
2576
|
+
const d = new Date(c.date)
|
|
2577
|
+
if (Number.isNaN(d.valueOf())) return
|
|
2578
|
+
const h = d.getHours()
|
|
2579
|
+
let overtime = null
|
|
2580
|
+
if (h >= endHour && h < 24) overtime = h - endHour
|
|
2581
|
+
else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
|
|
2582
|
+
if (overtime == null) return
|
|
2583
|
+
|
|
2584
|
+
const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
2585
|
+
if (!monthMax.has(mKey)) monthMax.set(mKey, new Map())
|
|
2586
|
+
const m = monthMax.get(mKey)
|
|
2587
|
+
const author = c.author || 'unknown'
|
|
2588
|
+
const cur = m.get(author)
|
|
2589
|
+
if (!cur || overtime > cur.max) {
|
|
2590
|
+
m.set(author, { max: overtime, date: d.toISOString().slice(0, 10) })
|
|
2591
|
+
}
|
|
2592
|
+
})
|
|
2593
|
+
|
|
2594
|
+
const curMap = monthMax.get(curKey) || new Map()
|
|
2595
|
+
const prevMap = monthMax.get(prevKey) || new Map()
|
|
2596
|
+
|
|
2597
|
+
let topAuthor = null
|
|
2598
|
+
let top = { max: -1, date: null }
|
|
2599
|
+
curMap.forEach((v, k) => {
|
|
2600
|
+
if (v.max > top.max) {
|
|
2601
|
+
top = v
|
|
2602
|
+
topAuthor = k
|
|
2603
|
+
}
|
|
2604
|
+
})
|
|
2605
|
+
|
|
2606
|
+
let prevMax = -1
|
|
2607
|
+
prevMap.forEach((v) => {
|
|
2608
|
+
if (v.max > prevMax) prevMax = v.max
|
|
2609
|
+
})
|
|
2610
|
+
|
|
2611
|
+
const lines = []
|
|
2612
|
+
lines.push('【本月最晚加班风险】')
|
|
2613
|
+
|
|
2614
|
+
if (top.max < 0) {
|
|
2615
|
+
lines.push('本月尚无下班后/凌晨提交,未发现明显风险。')
|
|
2616
|
+
} else {
|
|
2617
|
+
let trend = '暂无上月对比'
|
|
2618
|
+
if (prevMax >= 0) {
|
|
2619
|
+
if (top.max > prevMax) trend = '较上月更晚'
|
|
2620
|
+
else if (top.max < prevMax) trend = '较上月提前'
|
|
2621
|
+
else trend = '与上月持平'
|
|
2622
|
+
}
|
|
2623
|
+
lines.push(
|
|
2624
|
+
`${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend}。`
|
|
2625
|
+
)
|
|
2626
|
+
if (top.max >= 2) {
|
|
2627
|
+
lines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
|
|
2628
|
+
} else if (top.max >= 1) {
|
|
2629
|
+
lines.push('已超过 1 小时,注意控制夜间工作时长。')
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
box.innerHTML = `
|
|
2634
|
+
<div class="risk-summary">
|
|
2635
|
+
<div class="risk-title">【本月最晚加班风险】</div>
|
|
2636
|
+
<ul>
|
|
2637
|
+
${lines
|
|
2638
|
+
.slice(1)
|
|
2639
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2640
|
+
.join('')}
|
|
2641
|
+
</ul>
|
|
2642
|
+
</div>
|
|
2643
|
+
`
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
// ========= 开发者 午休最晚提交(小时) =========
|
|
2647
|
+
function buildAuthorLunchDataset(commits, type, lunchStart = 12, lunchEnd = 14) {
|
|
2648
|
+
const byAuthor = new Map()
|
|
2649
|
+
const periods = new Set()
|
|
2650
|
+
|
|
2651
|
+
commits.forEach((c) => {
|
|
2652
|
+
const d = new Date(c.date)
|
|
2653
|
+
if (Number.isNaN(d.valueOf())) return
|
|
2654
|
+
const h = d.getHours()
|
|
2655
|
+
const m = d.getMinutes()
|
|
2656
|
+
// 只考虑午休时间段内的提交
|
|
2657
|
+
if (!(h >= lunchStart && h < lunchEnd)) return
|
|
2658
|
+
|
|
2659
|
+
let key
|
|
2660
|
+
if (type === 'daily') key = d.toISOString().slice(0, 10)
|
|
2661
|
+
else if (type === 'weekly') key = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
2662
|
+
else key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
2663
|
+
if (!key) return
|
|
2664
|
+
periods.add(key)
|
|
2665
|
+
|
|
2666
|
+
const author = c.author || 'unknown'
|
|
2667
|
+
if (!byAuthor.has(author)) byAuthor.set(author, {})
|
|
2668
|
+
const obj = byAuthor.get(author)
|
|
2669
|
+
// 以小时小数表示提交时间(例如 12.5 表示 12:30),后者越大表示越靠近午休结束
|
|
2670
|
+
const hourDecimal = h + m / 60
|
|
2671
|
+
obj[key] = Math.max(obj[key] || 0, hourDecimal)
|
|
2672
|
+
})
|
|
2673
|
+
|
|
2674
|
+
const allPeriods = Array.from(periods).sort()
|
|
2675
|
+
const authors = Array.from(byAuthor.keys()).sort()
|
|
2676
|
+
const series = authors.map((a) => ({
|
|
2677
|
+
name: a,
|
|
2678
|
+
type: 'line',
|
|
2679
|
+
smooth: true,
|
|
2680
|
+
data: allPeriods.map((p) => byAuthor.get(a)[p] || 0)
|
|
2681
|
+
}))
|
|
2682
|
+
return { authors, allPeriods, series }
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
function formatHourDecimal(h) {
|
|
2686
|
+
if (h == null || h === 0) return '-'
|
|
2687
|
+
const hh = Math.floor(h)
|
|
2688
|
+
const mm = Math.round((h - hh) * 60)
|
|
2689
|
+
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
function drawAuthorLunchTrends(commits, stats) {
|
|
2693
|
+
const el = document.getElementById('chartAuthorLunch')
|
|
2694
|
+
if (!el) return null
|
|
2695
|
+
const chart = echarts.init(el)
|
|
2696
|
+
|
|
2697
|
+
const lunchStart = typeof stats.lunchStart === 'number' ? stats.lunchStart : (window.__lunchStart ?? 12)
|
|
2698
|
+
const lunchEnd = typeof stats.lunchEnd === 'number' ? stats.lunchEnd : (window.__lunchEnd ?? 14)
|
|
2699
|
+
|
|
2700
|
+
function render(type) {
|
|
2701
|
+
const ds = buildAuthorLunchDataset(commits, type, lunchStart, lunchEnd)
|
|
2702
|
+
ds.rangeMap = {}
|
|
2703
|
+
for (const period of ds.allPeriods) {
|
|
2704
|
+
if (period.includes('-W')) {
|
|
2705
|
+
const [yy, ww] = period.split('-W')
|
|
2706
|
+
ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
chart.setOption({
|
|
2711
|
+
tooltip: {
|
|
2712
|
+
trigger: 'axis',
|
|
2713
|
+
formatter(params) {
|
|
2714
|
+
if (!params || !params.length) return ''
|
|
2715
|
+
const label = params[0].axisValue
|
|
2716
|
+
const isWeekly = type === 'weekly'
|
|
2717
|
+
|
|
2718
|
+
let extra = ''
|
|
2719
|
+
if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
|
|
2720
|
+
const { start, end } = ds.rangeMap[label]
|
|
2721
|
+
extra = `<div style="margin-top:4px;color:#999;font-size:12px">周区间:${start} ~ ${end}</div>`
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
const lines = params
|
|
2725
|
+
.filter((i) => i.data > 0)
|
|
2726
|
+
.sort((a, b) => (b.data || 0) - (a.data || 0) || String(a.seriesName).localeCompare(String(b.seriesName)))
|
|
2727
|
+
.map((item) => `${item.marker}${item.seriesName}: ${formatHourDecimal(item.data)}`)
|
|
2728
|
+
.join('<br/>')
|
|
2729
|
+
|
|
2730
|
+
return `<div>${label}</div>${extra}${lines}`
|
|
2731
|
+
}
|
|
2732
|
+
},
|
|
2733
|
+
legend: { data: ds.authors },
|
|
2734
|
+
xAxis: { type: 'category', data: ds.allPeriods },
|
|
2735
|
+
yAxis: { type: 'value', name: '时间(小时)', min: lunchStart, max: lunchEnd },
|
|
2736
|
+
series: ds.series
|
|
2737
|
+
})
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
render('daily')
|
|
2741
|
+
|
|
2742
|
+
const tabs = document.querySelectorAll('#tabsLunch button')
|
|
2743
|
+
tabs.forEach((btnEl) => {
|
|
2744
|
+
btnEl.addEventListener('click', () => {
|
|
2745
|
+
tabs.forEach((b) => b.classList.remove('active'))
|
|
2746
|
+
btnEl.classList.add('active')
|
|
2747
|
+
render(btnEl.dataset.type)
|
|
2748
|
+
})
|
|
2749
|
+
})
|
|
2750
|
+
|
|
2751
|
+
renderLunchWeeklyRankSummary(commits, { lunchStart, lunchEnd })
|
|
2752
|
+
renderLunchWeeklyRiskSummary(commits, { lunchStart, lunchEnd })
|
|
2753
|
+
renderLunchMonthlyRankSummary(commits, { lunchStart, lunchEnd })
|
|
2754
|
+
renderLunchMonthlyRiskSummary(commits, { lunchStart, lunchEnd })
|
|
2755
|
+
|
|
2756
|
+
// 点击事件:点击某个数据点(作者+周期)打开侧栏,展示该作者在该周期午休时间段内的提交明细
|
|
2757
|
+
chart.on('click', (p) => {
|
|
2758
|
+
try {
|
|
2759
|
+
if (!p || p.componentType !== 'series') return
|
|
2760
|
+
const label = p.axisValue || p.name
|
|
2761
|
+
const author = p.seriesName
|
|
2762
|
+
if (!label || !author) return
|
|
2763
|
+
|
|
2764
|
+
// 识别当前 tabs 类型(daily|weekly|monthly)
|
|
2765
|
+
const type = document.querySelector('#tabsLunch button.active')?.dataset.type || 'daily'
|
|
2766
|
+
|
|
2767
|
+
// 过滤 commits:作者匹配 + 在午休时间段内 + 在所选周期内
|
|
2768
|
+
const filteredCommits = commits.filter((c) => {
|
|
2769
|
+
const a = c.author || 'unknown'
|
|
2770
|
+
if (a !== author) return false
|
|
2771
|
+
const d = new Date(c.date)
|
|
2772
|
+
if (Number.isNaN(d.valueOf())) return false
|
|
2773
|
+
const h = d.getHours()
|
|
2774
|
+
const m = d.getMinutes()
|
|
2775
|
+
if (!(h >= lunchStart && h < lunchEnd)) return false
|
|
2776
|
+
|
|
2777
|
+
if (type === 'daily') {
|
|
2778
|
+
return d.toISOString().slice(0, 10) === label
|
|
2779
|
+
}
|
|
2780
|
+
if (type === 'weekly') {
|
|
2781
|
+
// label 格式 YYYY-Www
|
|
2782
|
+
if (!label.includes('-W')) return false
|
|
2783
|
+
const [yy, ww] = label.split('-W')
|
|
2784
|
+
const range = getISOWeekRange(Number(yy), Number(ww))
|
|
2785
|
+
const day = d.toISOString().slice(0, 10)
|
|
2786
|
+
return day >= range.start && day <= range.end
|
|
2787
|
+
}
|
|
2788
|
+
// monthly
|
|
2789
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
2790
|
+
return month === label
|
|
2791
|
+
})
|
|
2792
|
+
|
|
2793
|
+
filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
2794
|
+
|
|
2795
|
+
if (type === 'weekly') {
|
|
2796
|
+
const weeklyItem = { outsideWorkCount: filteredCommits.length, outsideWorkRate: 0 }
|
|
2797
|
+
showSideBarForWeek({ period: label, weeklyItem, commits: filteredCommits, titleDrawer: `${author} 午休本周提交详情` })
|
|
2798
|
+
} else {
|
|
2799
|
+
showDayDetailSidebar({ date: label, count: filteredCommits.length, commits: filteredCommits, titleDrawer: `${author} 午休 ${type} 提交` })
|
|
2800
|
+
}
|
|
2801
|
+
} catch (err) {
|
|
2802
|
+
console.warn('Lunch chart click handler error', err)
|
|
2803
|
+
}
|
|
2804
|
+
})
|
|
2805
|
+
|
|
2806
|
+
return chart
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
function renderLunchWeeklyRankSummary(commits, { lunchStart = 12, lunchEnd = 14 } = {}) {
|
|
2810
|
+
const box = document.getElementById('lunchWeeklyRankSummary')
|
|
2811
|
+
if (!box) return
|
|
2812
|
+
|
|
2813
|
+
const now = new Date()
|
|
2814
|
+
const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
|
|
2815
|
+
|
|
2816
|
+
const weekDays = new Map() // week -> Map(author -> Set(dates))
|
|
2817
|
+
commits.forEach((c) => {
|
|
2818
|
+
const d = new Date(c.date)
|
|
2819
|
+
if (Number.isNaN(d.valueOf())) return
|
|
2820
|
+
const h = d.getHours()
|
|
2821
|
+
const m = d.getMinutes()
|
|
2822
|
+
if (!(h >= lunchStart && h < lunchEnd)) return
|
|
2823
|
+
const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
2824
|
+
if (wKey !== curKey) return
|
|
2825
|
+
const author = c.author || 'unknown'
|
|
2826
|
+
if (!weekDays.has(author)) weekDays.set(author, new Set())
|
|
2827
|
+
weekDays.get(author).add(d.toISOString().slice(0, 10))
|
|
2828
|
+
})
|
|
2829
|
+
|
|
2830
|
+
const weeklyRanks = []
|
|
2831
|
+
weekDays.forEach((set, author) => {
|
|
2832
|
+
weeklyRanks.push({ author, days: set.size })
|
|
2833
|
+
})
|
|
2834
|
+
weeklyRanks.sort((a, b) => b.days - a.days || String(a.author).localeCompare(String(b.author)))
|
|
2835
|
+
|
|
2836
|
+
const lines = []
|
|
2837
|
+
lines.push('【本周午休清醒者排行榜】')
|
|
2838
|
+
if (weeklyRanks.length === 0) {
|
|
2839
|
+
lines.push('本周无人午休提交,暂无清醒者排行榜。')
|
|
2840
|
+
} else {
|
|
2841
|
+
weeklyRanks.forEach((r, idx) => {
|
|
2842
|
+
const rank = idx + 1
|
|
2843
|
+
const medal = rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
|
|
2844
|
+
const title = rank === 1 ? '(状元・昼魔侠)' : ''
|
|
2845
|
+
lines.push(`${rank}. ${medal}${r.author} — ${r.days} 天${title}`)
|
|
2846
|
+
})
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
box.innerHTML = `
|
|
2850
|
+
<div class="risk-summary">
|
|
2851
|
+
<div class="risk-title">【本周午休清醒者排行榜】</div>
|
|
2852
|
+
<ul>
|
|
2853
|
+
${lines
|
|
2854
|
+
.slice(1)
|
|
2855
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2856
|
+
.join('')}
|
|
2857
|
+
</ul>
|
|
2858
|
+
</div>
|
|
2859
|
+
`
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
function renderLunchWeeklyRiskSummary(commits, { lunchStart = 12, lunchEnd = 14 } = {}) {
|
|
2863
|
+
const box = document.getElementById('lunchWeeklyRiskSummary')
|
|
2864
|
+
if (!box) return
|
|
2865
|
+
|
|
2866
|
+
const now = new Date()
|
|
2867
|
+
const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
|
|
2868
|
+
const prev = new Date(now)
|
|
2869
|
+
prev.setDate(prev.getDate() - 7)
|
|
2870
|
+
const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
|
|
2871
|
+
|
|
2872
|
+
const weekMax = new Map() // week -> Map(author -> {val, date, time})
|
|
2873
|
+
commits.forEach((c) => {
|
|
2874
|
+
const d = new Date(c.date)
|
|
2875
|
+
if (Number.isNaN(d.valueOf())) return
|
|
2876
|
+
const h = d.getHours()
|
|
2877
|
+
const m = d.getMinutes()
|
|
2878
|
+
if (!(h >= lunchStart && h < lunchEnd)) return
|
|
2879
|
+
const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
|
|
2880
|
+
if (!wKey) return
|
|
2881
|
+
|
|
2882
|
+
if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
|
|
2883
|
+
const mMap = weekMax.get(wKey)
|
|
2884
|
+
const author = c.author || 'unknown'
|
|
2885
|
+
const val = h + m / 60
|
|
2886
|
+
const cur = mMap.get(author)
|
|
2887
|
+
if (!cur || val > cur.val) mMap.set(author, { val, date: d.toISOString().slice(0, 10), time: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` })
|
|
2888
|
+
})
|
|
2889
|
+
|
|
2890
|
+
const curMap = weekMax.get(curKey) || new Map()
|
|
2891
|
+
const prevMap = weekMax.get(prevKey) || new Map()
|
|
2892
|
+
|
|
2893
|
+
let topAuthor = null
|
|
2894
|
+
let top = { val: -1, date: null, time: null }
|
|
2895
|
+
curMap.forEach((v, k) => {
|
|
2896
|
+
if (v.val > top.val) {
|
|
2897
|
+
top = v
|
|
2898
|
+
topAuthor = k
|
|
2899
|
+
}
|
|
2900
|
+
})
|
|
2901
|
+
|
|
2902
|
+
let prevMax = -1
|
|
2903
|
+
prevMap.forEach((v) => {
|
|
2904
|
+
if (v.val > prevMax) prevMax = v.val
|
|
2905
|
+
})
|
|
2906
|
+
|
|
2907
|
+
const lines = []
|
|
2908
|
+
lines.push('【本周午休最晚提交风险】')
|
|
2909
|
+
|
|
2910
|
+
if (top.val < 0) {
|
|
2911
|
+
lines.push('本周午休期间暂无提交记录。')
|
|
2912
|
+
} else {
|
|
2913
|
+
let trend = '暂无上周对比'
|
|
2914
|
+
if (prevMax >= 0) {
|
|
2915
|
+
if (top.val > prevMax) trend = '较上周更晚'
|
|
2916
|
+
else if (top.val < prevMax) trend = '较上周提前'
|
|
2917
|
+
else trend = '与上周持平'
|
|
2918
|
+
}
|
|
2919
|
+
lines.push(`${topAuthor} 本周午休最晚提交:${top.time}(${top.date}),${trend}。)`)
|
|
2920
|
+
if (top.val >= lunchEnd - 0.5) lines.push('存在午间延迟提交风险,请关注短时间内频繁占用午休。')
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
box.innerHTML = `
|
|
2924
|
+
<div class="risk-summary">
|
|
2925
|
+
<div class="risk-title">【本周午休最晚提交风险】</div>
|
|
2926
|
+
<ul>
|
|
2927
|
+
${lines
|
|
2928
|
+
.slice(1)
|
|
2929
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2930
|
+
.join('')}
|
|
2931
|
+
</ul>
|
|
2932
|
+
</div>
|
|
2933
|
+
`
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
function renderLunchMonthlyRankSummary(commits, { lunchStart = 12, lunchEnd = 14 } = {}) {
|
|
2937
|
+
const box = document.getElementById('lunchMonthlyRankSummary')
|
|
2938
|
+
if (!box) return
|
|
2939
|
+
|
|
2940
|
+
const now = new Date()
|
|
2941
|
+
const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
2942
|
+
|
|
2943
|
+
const monthDays = new Map() // author -> Set(dates)
|
|
2944
|
+
commits.forEach((c) => {
|
|
2945
|
+
const d = new Date(c.date)
|
|
2946
|
+
if (Number.isNaN(d.valueOf())) return
|
|
2947
|
+
const h = d.getHours()
|
|
2948
|
+
const m = d.getMinutes()
|
|
2949
|
+
if (!(h >= lunchStart && h < lunchEnd)) return
|
|
2950
|
+
const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
2951
|
+
if (mKey !== curKey) return
|
|
2952
|
+
const author = c.author || 'unknown'
|
|
2953
|
+
if (!monthDays.has(author)) monthDays.set(author, new Set())
|
|
2954
|
+
monthDays.get(author).add(d.toISOString().slice(0, 10))
|
|
2955
|
+
})
|
|
2956
|
+
|
|
2957
|
+
const monthlyRanks = []
|
|
2958
|
+
monthDays.forEach((set, author) => {
|
|
2959
|
+
monthlyRanks.push({ author, days: set.size })
|
|
2960
|
+
})
|
|
2961
|
+
monthlyRanks.sort((a, b) => b.days - a.days || String(a.author).localeCompare(String(b.author)))
|
|
2962
|
+
|
|
2963
|
+
const lines = []
|
|
2964
|
+
lines.push('【本月午休清醒者排行榜】')
|
|
2965
|
+
if (monthlyRanks.length === 0) {
|
|
2966
|
+
lines.push('本月无人午休提交,暂无清醒者排行榜。')
|
|
2967
|
+
} else {
|
|
2968
|
+
monthlyRanks.forEach((r, idx) => {
|
|
2969
|
+
const rank = idx + 1
|
|
2970
|
+
const medal = rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
|
|
2971
|
+
const title = rank === 1 ? '(状元・昼魔侠)' : ''
|
|
2972
|
+
lines.push(`${rank}. ${medal}${r.author} — ${r.days} 天${title}`)
|
|
2973
|
+
})
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
box.innerHTML = `
|
|
2977
|
+
<div class="risk-summary">
|
|
2978
|
+
<div class="risk-title">【本月午休清醒者排行榜】</div>
|
|
2979
|
+
<ul>
|
|
2980
|
+
${lines
|
|
2981
|
+
.slice(1)
|
|
2982
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
2983
|
+
.join('')}
|
|
2984
|
+
</ul>
|
|
2985
|
+
</div>
|
|
2986
|
+
`
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
function renderLunchMonthlyRiskSummary(commits, { lunchStart = 12, lunchEnd = 14 } = {}) {
|
|
2990
|
+
const box = document.getElementById('lunchMonthlyRiskSummary')
|
|
2991
|
+
if (!box) return
|
|
2992
|
+
|
|
2993
|
+
const now = new Date()
|
|
2994
|
+
const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
|
2995
|
+
const prev = new Date(now)
|
|
2996
|
+
prev.setMonth(prev.getMonth() - 1)
|
|
2997
|
+
const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
|
|
2998
|
+
|
|
2999
|
+
const monthMax = new Map()
|
|
3000
|
+
commits.forEach((c) => {
|
|
3001
|
+
const d = new Date(c.date)
|
|
3002
|
+
if (Number.isNaN(d.valueOf())) return
|
|
3003
|
+
const h = d.getHours()
|
|
3004
|
+
const m = d.getMinutes()
|
|
3005
|
+
if (!(h >= lunchStart && h < lunchEnd)) return
|
|
3006
|
+
const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
3007
|
+
if (!monthMax.has(mKey)) monthMax.set(mKey, new Map())
|
|
3008
|
+
const mm = monthMax.get(mKey)
|
|
3009
|
+
const author = c.author || 'unknown'
|
|
3010
|
+
const val = h + m / 60
|
|
3011
|
+
const cur = mm.get(author)
|
|
3012
|
+
if (!cur || val > cur.val) mm.set(author, { val, date: d.toISOString().slice(0, 10), time: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` })
|
|
3013
|
+
})
|
|
3014
|
+
|
|
3015
|
+
const curMap = monthMax.get(curKey) || new Map()
|
|
3016
|
+
const prevMap = monthMax.get(prevKey) || new Map()
|
|
3017
|
+
|
|
3018
|
+
let topAuthor = null
|
|
3019
|
+
let top = { val: -1, date: null }
|
|
3020
|
+
curMap.forEach((v, k) => {
|
|
3021
|
+
if (v.val > top.val) {
|
|
3022
|
+
top = v
|
|
3023
|
+
topAuthor = k
|
|
3024
|
+
}
|
|
3025
|
+
})
|
|
3026
|
+
|
|
3027
|
+
let prevMax = -1
|
|
3028
|
+
prevMap.forEach((v) => {
|
|
3029
|
+
if (v.val > prevMax) prevMax = v.val
|
|
3030
|
+
})
|
|
3031
|
+
|
|
3032
|
+
const lines = []
|
|
3033
|
+
lines.push('【本月午休最晚提交风险】')
|
|
3034
|
+
|
|
3035
|
+
if (top.val < 0) {
|
|
3036
|
+
lines.push('本月午休期间暂无提交记录。')
|
|
3037
|
+
} else {
|
|
3038
|
+
let trend = '暂无上月对比'
|
|
3039
|
+
if (prevMax >= 0) {
|
|
3040
|
+
if (top.val > prevMax) trend = '较上月更晚'
|
|
3041
|
+
else if (top.val < prevMax) trend = '较上月提前'
|
|
3042
|
+
else trend = '与上月持平'
|
|
3043
|
+
}
|
|
3044
|
+
lines.push(`${topAuthor} 本月午休最晚提交:${top.time}(${top.date}),${trend}。)`)
|
|
3045
|
+
if (top.val >= lunchEnd - 0.5) lines.push('存在午间延迟提交风险,请关注短时间内频繁占用午休。')
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
box.innerHTML = `
|
|
3049
|
+
<div class="risk-summary">
|
|
3050
|
+
<div class="risk-title">【本月午休最晚提交风险】</div>
|
|
3051
|
+
<ul>
|
|
3052
|
+
${lines
|
|
3053
|
+
.slice(1)
|
|
3054
|
+
.map((l) => `<li>${escapeHtml(l)}</li>`)
|
|
3055
|
+
.join('')}
|
|
3056
|
+
</ul>
|
|
3057
|
+
</div>
|
|
3058
|
+
`
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
async function main() {
|
|
3062
|
+
const {
|
|
3063
|
+
commits,
|
|
3064
|
+
stats,
|
|
3065
|
+
weekly,
|
|
3066
|
+
monthly,
|
|
3067
|
+
latestByDay,
|
|
3068
|
+
config,
|
|
3069
|
+
authorChanges
|
|
3070
|
+
} = await loadData()
|
|
3071
|
+
commitsAll = commits
|
|
3072
|
+
filtered = commitsAll.slice()
|
|
3073
|
+
window.__overtimeEndHour =
|
|
3074
|
+
stats && typeof stats.endHour === 'number'
|
|
3075
|
+
? stats.endHour
|
|
3076
|
+
: (config.endHour ?? 18)
|
|
3077
|
+
window.__overnightCutoff =
|
|
3078
|
+
typeof config.overnightCutoff === 'number' ? config.overnightCutoff : 6
|
|
3079
|
+
// lunch config(用于午休图表)
|
|
3080
|
+
window.__lunchStart = stats && typeof stats.lunchStart === 'number' ? stats.lunchStart : (config.lunchStart ?? 12)
|
|
3081
|
+
window.__lunchEnd = stats && typeof stats.lunchEnd === 'number' ? stats.lunchEnd : (config.lunchEnd ?? 14)
|
|
3082
|
+
initTableControls()
|
|
3083
|
+
updatePager()
|
|
3084
|
+
renderCommitsTablePage()
|
|
3085
|
+
|
|
3086
|
+
drawHourlyOvertime(stats, (hour) => {
|
|
3087
|
+
// 使用举例
|
|
3088
|
+
const hourCommitsDetail = groupCommitsByHour(commits)
|
|
3089
|
+
// 将 commit 列表传给侧栏(若没有详情,则传空数组)
|
|
3090
|
+
showSideBarForHour({
|
|
3091
|
+
hour,
|
|
3092
|
+
commitsOrCount: hourCommitsDetail[hour] || [],
|
|
3093
|
+
titleDrawer: '每小时加班分布'
|
|
3094
|
+
})
|
|
3095
|
+
})
|
|
3096
|
+
drawOutsideVsInside(stats)
|
|
3097
|
+
|
|
3098
|
+
// 按日提交趋势:点击某天打开抽屉,显示当日所有 commits
|
|
3099
|
+
drawDailyTrend(commits, showDayDetailSidebar)
|
|
3100
|
+
|
|
3101
|
+
// 周趋势:保持原有点击行为(显示该周详情)
|
|
3102
|
+
drawWeeklyTrend(weekly, commits, showSideBarForWeek)
|
|
3103
|
+
|
|
3104
|
+
// 月趋势(加班占比):点击某个月打开抽屉,显示该月所有 commits
|
|
3105
|
+
drawMonthlyTrend(monthly, commits, showDayDetailSidebar)
|
|
3106
|
+
|
|
3107
|
+
// 每日最晚提交时间(小时):点击某天打开抽屉,显示当日所有 commits
|
|
3108
|
+
drawLatestHourDaily(latestByDay, commits, showDayDetailSidebar)
|
|
3109
|
+
|
|
3110
|
+
// 每日超过下班的小时数:点击某天打开抽屉,显示当日所有 commits
|
|
3111
|
+
drawDailySeverity(latestByDay, commits, showDayDetailSidebar)
|
|
3112
|
+
|
|
3113
|
+
const daily = drawDailyTrendSeverity(commits, weekly, showDayDetailSidebar)
|
|
3114
|
+
|
|
3115
|
+
console.log('最累的一天:', daily.analysis.mostTiredDay)
|
|
3116
|
+
|
|
3117
|
+
drawChangeTrends(authorChanges)
|
|
3118
|
+
drawAuthorOvertimeTrends(commits, stats)
|
|
3119
|
+
drawAuthorLatestOvertimeTrends(commits, stats)
|
|
3120
|
+
drawAuthorLunchTrends(commits, stats)
|
|
3121
|
+
computeAndRenderLatestOvertime(latestByDay)
|
|
3122
|
+
renderKpi(stats)
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
// 抽屉关闭交互(按钮 + 点击遮罩)
|
|
3126
|
+
document.getElementById('sidebarClose').onclick = () => {
|
|
3127
|
+
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
3128
|
+
const backdrop = document.getElementById('sidebarBackdrop')
|
|
3129
|
+
if (backdrop) backdrop.classList.remove('show')
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
const sidebarBackdropEl = document.getElementById('sidebarBackdrop')
|
|
3133
|
+
if (sidebarBackdropEl) {
|
|
3134
|
+
sidebarBackdropEl.addEventListener('click', () => {
|
|
3135
|
+
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
3136
|
+
sidebarBackdropEl.classList.remove('show')
|
|
3137
|
+
})
|
|
3138
|
+
}
|
|
3139
|
+
main()
|