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>
376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
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',
|
||
});
|
||
});
|