import { Hono } from 'hono'; import { db } from '../db/index'; import { projects, gitCommits, gitPRs, objectives, keyResults, users, projectRepos, krLogs } from '../db/schema'; import { eq, desc, gte, inArray } from 'drizzle-orm'; import { getAllowedProjectIds } from '../services/permissions'; import dayjs from 'dayjs'; /** 根据项目绑定的仓库名,提取纯仓库名 Set */ async function getAllowedRepoNames(allowedProjectIds: string[]): Promise> { if (allowedProjectIds.length === 0) return new Set(); const repos = await db.select().from(projectRepos) .where(inArray(projectRepos.projectId, allowedProjectIds)); return new Set(repos.map(r => { let cleaned = r.repoName.trim().replace(/\.git$/, ''); if (cleaned.includes('://')) { try { const parts = new URL(cleaned).pathname.split('/').filter(Boolean); return parts[parts.length - 1] || cleaned; } catch {} } if (cleaned.includes('/')) return cleaned.split('/').pop() || cleaned; return cleaned; })); } export const overviewRoutes = new Hono(); async function getRecentCommits(allowedRepos?: Set) { const recent = await db.select().from(gitCommits).orderBy(desc(gitCommits.committedAt)).limit(50); const allUsers = await db.select().from(users); const userMap = new Map(allUsers.map(u => [u.id, u.displayName])); let filtered = recent; if (allowedRepos) { filtered = recent.filter(c => allowedRepos.has(c.repoName)); } return filtered.slice(0, 15).map(c => ({ sha: c.sha?.slice(0, 7) || '', message: (c.message || '').split('\n')[0].slice(0, 60), authorName: c.userId ? userMap.get(c.userId) || c.authorName : c.authorName, repoName: c.repoName, committedAt: c.committedAt instanceof Date ? c.committedAt.toISOString() : c.committedAt, })); } overviewRoutes.get('/overview', async (c) => { const user = c.get('user'); const period = c.req.query('period'); let projectIds = c.req.query('projectIds')?.split(',').filter(Boolean) || []; // 观察者:强制限定到已分配的项目 const allowedIds = await getAllowedProjectIds(user); let mustFilterByProject = false; if (allowedIds !== null) { mustFilterByProject = true; if (projectIds.length > 0) { projectIds = projectIds.filter(id => allowedIds.includes(id)); } else { projectIds = allowedIds; } } // 观察者:获取可访问的仓库名(用于过滤 git 相关数据) const allowedRepos = mustFilterByProject ? await getAllowedRepoNames(projectIds) : undefined; // 观察者:获取可访问项目下的 objective IDs(用于过滤 KR 数据) let allowedObjIds: Set | undefined; if (mustFilterByProject) { const objs = projectIds.length > 0 ? (await db.select().from(objectives)).filter(o => o.projectId && projectIds.includes(o.projectId)) : []; allowedObjIds = new Set(objs.map(o => o.id)); } // ─── 性能优化:一次性批量拉取,内存里做 join,避免 N+1 ─── const [allProjects, allObjectivesRaw, allKRs, allUsersData] = await Promise.all([ db.select().from(projects), db.select().from(objectives), db.select().from(keyResults), db.select().from(users), ]); // 索引化方便 O(1) 查找 const objectivesByProject = new Map(); for (const o of allObjectivesRaw) { if (!o.projectId) continue; if (!objectivesByProject.has(o.projectId)) objectivesByProject.set(o.projectId, []); objectivesByProject.get(o.projectId)!.push(o); } const krsByObjective = new Map(); for (const kr of allKRs) { if (!krsByObjective.has(kr.objectiveId)) krsByObjective.set(kr.objectiveId, []); krsByObjective.get(kr.objectiveId)!.push(kr); } const usersById = new Map(allUsersData.map(u => [u.id, u])); const projectsById = new Map(allProjects.map(p => [p.id, p])); const objectivesById = new Map(allObjectivesRaw.map(o => [o.id, o])); // 1. 各项目 OKR 整体进度 const projectOKRProgress: { projectId: string; name: string; identifier: string; progress: number; objectiveCount: number }[] = []; for (const proj of allProjects) { if ((mustFilterByProject || projectIds.length > 0) && !projectIds.includes(proj.id)) continue; let projObjectives = objectivesByProject.get(proj.id) || []; if (period) { projObjectives = projObjectives.filter(o => o.period === period); } const avgProgress = projObjectives.length > 0 ? Math.round(projObjectives.reduce((s, o) => s + (o.progress || 0), 0) / projObjectives.length) : 0; projectOKRProgress.push({ projectId: proj.id, name: proj.name, identifier: proj.identifier || '', progress: avgProgress, objectiveCount: projObjectives.length, }); } // 2. KR 完成状态分布 let filteredKRs = allKRs; if (mustFilterByProject || projectIds.length > 0) { const projObjIds = new Set( allObjectivesRaw.filter(o => o.projectId && projectIds.includes(o.projectId)).map(o => o.id) ); filteredKRs = allKRs.filter(kr => projObjIds.has(kr.objectiveId)); } const krDistribution = { completed: filteredKRs.filter(kr => (kr.currentValue || 0) >= (kr.targetValue || 100)).length, inProgress: filteredKRs.filter(kr => { const cv = kr.currentValue || 0; const tv = kr.targetValue || 100; return cv > 0 && cv < tv; }).length, notStarted: filteredKRs.filter(kr => (kr.currentValue || 0) === 0).length, total: filteredKRs.length, }; // 3. 产品线进度(读 OKR 进度) const projectProgress = projectOKRProgress.map(p => ({ projectId: p.projectId, name: p.name, identifier: p.identifier, currentCycleProgress: p.progress, totalPoints: p.objectiveCount, completedPoints: 0, })); // 4. Weekly Code Activity (last 12 weeks) const twelveWeeksAgo = dayjs().subtract(12, 'week').startOf('week').toDate(); const [commitsRaw, prsRaw] = await Promise.all([ db.select().from(gitCommits).where(gte(gitCommits.committedAt, twelveWeeksAgo)), db.select().from(gitPRs).where(gte(gitPRs.createdAt, twelveWeeksAgo)), ]); let commits = commitsRaw; let prs = prsRaw; if (allowedRepos) { commits = commits.filter(c => allowedRepos.has(c.repoName)); prs = prs.filter(p => allowedRepos.has(p.repoName)); } const allUsers = allUsersData; // 复用上面已 fetch 的 const weekMap: Record> = {}; for (let i = 0; i < 12; i++) { const ws = dayjs().subtract(11 - i, 'week').startOf('week').format('YYYY-MM-DD'); weekMap[ws] = {}; } for (const commit of commits) { const ws = dayjs(commit.committedAt).startOf('week').format('YYYY-MM-DD'); if (weekMap[ws] && commit.userId) { if (!weekMap[ws][commit.userId]) weekMap[ws][commit.userId] = { commits: 0, prs: 0 }; weekMap[ws][commit.userId].commits++; } } for (const pr of prs) { const ws = dayjs(pr.createdAt).startOf('week').format('YYYY-MM-DD'); if (weekMap[ws] && pr.userId) { if (!weekMap[ws][pr.userId]) weekMap[ws][pr.userId] = { commits: 0, prs: 0 }; weekMap[ws][pr.userId].prs++; } } const userMap = new Map(allUsers.map(u => [u.id, u.displayName])); const weeklyCodeActivity = { weeks: Object.entries(weekMap).map(([weekStart, members]) => ({ weekStart, members: Object.entries(members).map(([userId, data]) => ({ userId, name: userMap.get(userId) || 'Unknown', ...data, })), })), }; // 5. OKR Progress(用前面已 fetch 的 allObjectivesRaw + krsByObjective + usersById) let allObjectives = period ? allObjectivesRaw.filter(o => o.period === period) : allObjectivesRaw; if (mustFilterByProject || projectIds.length > 0) { allObjectives = allObjectives.filter(o => o.projectId && projectIds.includes(o.projectId)); } const okrProgress = allObjectives.map(obj => { const krs = krsByObjective.get(obj.id) || []; const owner = obj.ownerId ? usersById.get(obj.ownerId) : null; return { id: obj.id, title: obj.title, ownerName: owner?.displayName || '未指定', progress: obj.progress || 0, startDate: obj.startDate || null, endDate: obj.endDate || null, keyResults: krs.map(kr => ({ title: kr.title, current: kr.currentValue || 0, target: kr.targetValue, unit: kr.unit || '', })), }; }); // 6. 指定周的 KR(支持 weekOffset 参数,0=本周,1=下周,-1=上周) const weekOffset = parseInt(c.req.query('weekOffset') || '0'); const weekStart = dayjs().startOf('week').add(weekOffset, 'week'); const weekEnd = dayjs().endOf('week').add(weekOffset, 'week'); let allKRsRaw = allKRs; // 复用前面 fetch const allObjsMap = objectivesById; if (allowedObjIds) { allKRsRaw = allKRsRaw.filter(kr => allowedObjIds!.has(kr.objectiveId)); } const thisWeekKRs = allKRsRaw.filter(kr => { if (!kr.endDate) return false; const end = dayjs(kr.endDate); // 严格筛选:截止日期在本周一到本周日之间 return (end.isAfter(weekStart) || end.isSame(weekStart, 'day')) && (end.isBefore(weekEnd) || end.isSame(weekEnd, 'day')); }).sort((a, b) => { // 未完成的排前面,已完成的排后面;同状态按截止日期排 const aDone = (a.currentValue || 0) >= (a.targetValue || 100) ? 1 : 0; const bDone = (b.currentValue || 0) >= (b.targetValue || 100) ? 1 : 0; if (aDone !== bDone) return aDone - bDone; return dayjs(a.endDate).valueOf() - dayjs(b.endDate).valueOf(); }); const urgentKRs = []; for (const kr of thisWeekKRs.slice(0, 20)) { const obj = allObjsMap.get(kr.objectiveId); const owner = obj?.ownerId ? usersById.get(obj.ownerId) : null; const proj = obj?.projectId ? projectsById.get(obj.projectId) : null; const endDate = kr.endDate || ''; const isOverdue = dayjs(endDate).isBefore(dayjs().startOf('day')); const daysLeft = dayjs(endDate).startOf('day').diff(dayjs().startOf('day'), 'day'); const isCompleted = (kr.currentValue || 0) >= (kr.targetValue || 100); const krStatus = kr.status || 'active'; // 确定显示状态 let displayStatus: string; if (krStatus === 'cancelled') displayStatus = 'cancelled'; else if (krStatus === 'paused') displayStatus = 'paused'; else if (isCompleted) displayStatus = 'completed'; else if (isOverdue) displayStatus = 'overdue'; else displayStatus = 'active'; urgentKRs.push({ id: kr.id, title: kr.title, progress: kr.currentValue || 0, endDate, isOverdue: displayStatus === 'overdue', isCompleted: displayStatus === 'completed', isPaused: displayStatus === 'paused', isCancelled: displayStatus === 'cancelled', displayStatus, daysLeft, objectiveTitle: obj?.title || '', ownerName: owner?.displayName || '未指定', projectName: proj?.name || '', projectIdentifier: proj?.identifier || '', }); } // 7. 异常事项(逾期未完成 + 暂停 + 取消) const abnormalKRs = allKRsRaw.filter(kr => { // 暂停的 if (kr.status === 'paused') return true; // 取消的 if (kr.status === 'cancelled') return true; // 逾期未完成的(active 但截止日期已过且未 100%) if (kr.status === 'active' && kr.endDate && (kr.currentValue || 0) < (kr.targetValue || 100)) { return dayjs(kr.endDate).isBefore(dayjs().startOf('day')); } return false; }).sort((a, b) => { // 逾期排最前,暂停其次,取消最后 const order = (kr: any) => kr.status === 'active' ? 0 : kr.status === 'paused' ? 1 : 2; return order(a) - order(b); }); // 批量取异常 KRs 的最后日志(单次 IN 查询代替循环) const abnormalKrIds = abnormalKRs.slice(0, 20).map(k => k.id); const lastLogByKr = new Map(); if (abnormalKrIds.length > 0) { const logs = await db.select().from(krLogs) .where(inArray(krLogs.krId, abnormalKrIds)) .orderBy(desc(krLogs.createdAt)); for (const log of logs) { if (!lastLogByKr.has(log.krId)) lastLogByKr.set(log.krId, log.detail || ''); } } const overdueList = []; for (const kr of abnormalKRs.slice(0, 20)) { const obj = allObjsMap.get(kr.objectiveId); const owner = obj?.ownerId ? usersById.get(obj.ownerId) : null; const proj = obj?.projectId ? projectsById.get(obj.projectId) : null; const reason = lastLogByKr.get(kr.id) || ''; let itemStatus: string; let statusLabel: string; if (kr.status === 'paused') { itemStatus = 'paused'; statusLabel = '已暂停'; } else if (kr.status === 'cancelled') { itemStatus = 'cancelled'; statusLabel = '已取消'; } else { itemStatus = 'overdue'; statusLabel = '逾期' + dayjs().startOf('day').diff(dayjs(kr.endDate!).startOf('day'), 'day') + '天'; } overdueList.push({ id: kr.id, title: kr.title, progress: kr.currentValue || 0, endDate: kr.endDate, status: itemStatus, statusLabel, reason, ownerName: owner?.displayName || '未指定', projectName: proj?.name || '', projectIdentifier: proj?.identifier || '', }); } const activeKRs = urgentKRs.filter(kr => !kr.isCancelled && !kr.isPaused); const weeklyTotal = urgentKRs.length; const weeklyCompleted = urgentKRs.filter(kr => kr.isCompleted).length; const weeklyCancelled = urgentKRs.filter(kr => kr.isCancelled).length; const weeklyPaused = urgentKRs.filter(kr => kr.isPaused).length; const weeklyAvgProgress = activeKRs.length > 0 ? Math.round(activeKRs.reduce((s, kr) => s + kr.progress, 0) / activeKRs.length) : 0; return c.json({ code: 0, data: { projectOKRProgress, krDistribution, projectProgress, weeklyCodeActivity, okrProgress, urgentKRs, weeklyKRStats: { total: weeklyTotal, completed: weeklyCompleted, avgProgress: weeklyAvgProgress, weekOffset, weekLabel: weekStart.format('MM/DD') + ' ~ ' + weekEnd.format('MM/DD'), }, overdueKRs: overdueList, recentCommits: await getRecentCommits(allowedRepos), }, message: 'success', }); });