devperf/backend/src/routes/overview.ts
zyc 4a2ed8d414 feat(ui+perf): Editorial Data Console 重设计 + 接口性能 + ROI 权限锁
UI 重设计 (Editorial Data Console 风):
- 设计令牌系统: OKLCH 色彩 + Newsreader/Geist/JetBrains Mono 字体 + exp easing
- 全局表格基线 (.n-data-table 统一 editorial 风 + .table-shell 卡片容器)
- DataCard / Naive UI 主题对齐新 token (深墨青主色 + 暖琥珀强调)
- RoiDashboard: 3 KPI 卡片同字号 + chip 多色筛选 + section editorial 节奏
- ProjectRoiBoard: hero 卡 highlight + ytd-strip 节奏化 (10/13/15px 三层字号)
- ProjectList: 自适应卡片 + 产品线 NSelect 筛选 + 拆出独立"类型"列 + 文本链接操作
- RevenuePieChart 重设计: donut + 中心总额 + 底部水平图例 (替代外部 callout 截断)
- 全部页面 width:100% + clamp() 流体 padding,断点驱动 auto-fit 网格
- AppSidebar 项目子菜单按产品线分组 + 可折叠 + localStorage 持久化

接口性能优化 (N+1 → 批量 + Map 索引):
- /api/overview: 8.5s → 0.5s (17×) - 消除 3 处循环 SQL 查询
- /api/okr:     11.3s → 0.3s (37×) - getOKRByPeriod 一次性 inArray 批量
- ROI 三处时间窗 (aggregate/timeseries/events) launchedAt 截断对齐

ROI 权限锁:
- 全部 ROI 端点统一 admin (roiRoutes 全局 requireRole)
- 路由 /roi + /projects/:id/roi meta.roles=['admin']
- 侧边栏 ROI 入口 + 项目详情打标按钮/分类标签全部 v-if isAdmin

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:28:48 +08:00

376 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Set<string>> {
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<string>) {
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<string> | 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<string, typeof allObjectivesRaw>();
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<string, typeof allKRs>();
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<string, Record<string, { commits: number; prs: number }>> = {};
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<string, string>();
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',
});
});