diff --git a/backend/src/routes/overview.ts b/backend/src/routes/overview.ts index 3b9b3c5..f99af83 100644 --- a/backend/src/routes/overview.ts +++ b/backend/src/routes/overview.ts @@ -67,14 +67,35 @@ overviewRoutes.get('/overview', async (c) => { allowedObjIds = new Set(objs.map(o => o.id)); } - // 1. 各项目 OKR 整体进度(替代 Sprint 交付率) - const allProjects = await db.select().from(projects); - const projectOKRProgress: { projectId: string; name: string; identifier: string; progress: number; objectiveCount: number }[] = []; + // ─── 性能优化:一次性批量拉取,内存里做 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 = await db.select().from(objectives) - .where(eq(objectives.projectId, proj.id)); + let projObjectives = objectivesByProject.get(proj.id) || []; if (period) { projObjectives = projObjectives.filter(o => o.period === period); } @@ -90,14 +111,11 @@ overviewRoutes.get('/overview', async (c) => { }); } - // 2. KR 完成状态分布(替代任务状态分布) - const allKRs = await db.select().from(keyResults); + // 2. KR 完成状态分布 let filteredKRs = allKRs; if (mustFilterByProject || projectIds.length > 0) { const projObjIds = new Set( - (await db.select().from(objectives)) - .filter(o => o.projectId && projectIds.includes(o.projectId)) - .map(o => o.id) + allObjectivesRaw.filter(o => o.projectId && projectIds.includes(o.projectId)).map(o => o.id) ); filteredKRs = allKRs.filter(kr => projObjIds.has(kr.objectiveId)); } @@ -125,16 +143,17 @@ overviewRoutes.get('/overview', async (c) => { // 4. Weekly Code Activity (last 12 weeks) const twelveWeeksAgo = dayjs().subtract(12, 'week').startOf('week').toDate(); - let commits = await db.select().from(gitCommits) - .where(gte(gitCommits.committedAt, twelveWeeksAgo)); - let prs = await db.select().from(gitPRs) - .where(gte(gitPRs.createdAt, twelveWeeksAgo)); - // 观察者:按项目绑定仓库过滤 + 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 = await db.select().from(users); + const allUsers = allUsersData; // 复用上面已 fetch 的 const weekMap: Record> = {}; for (let i = 0; i < 12; i++) { @@ -170,23 +189,19 @@ overviewRoutes.get('/overview', async (c) => { })), }; - // 5. OKR Progress + // 5. OKR Progress(用前面已 fetch 的 allObjectivesRaw + krsByObjective + usersById) let allObjectives = period - ? await db.select().from(objectives).where(eq(objectives.period, period)) - : await db.select().from(objectives); + ? allObjectivesRaw.filter(o => o.period === period) + : allObjectivesRaw; if (mustFilterByProject || projectIds.length > 0) { allObjectives = allObjectives.filter(o => o.projectId && projectIds.includes(o.projectId)); } - const okrProgress = []; - for (const obj of allObjectives) { - const krs = await db.select().from(keyResults) - .where(eq(keyResults.objectiveId, obj.id)); - const owner = obj.ownerId - ? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) }) - : null; - okrProgress.push({ + 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 || '未指定', @@ -199,17 +214,16 @@ overviewRoutes.get('/overview', async (c) => { 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 = await db.select().from(keyResults); - const allObjsMap = new Map((await db.select().from(objectives)).map(o => [o.id, o])); - // 观察者:过滤到已分配项目的 KR + let allKRsRaw = allKRs; // 复用前面 fetch + const allObjsMap = objectivesById; if (allowedObjIds) { allKRsRaw = allKRsRaw.filter(kr => allowedObjIds!.has(kr.objectiveId)); } @@ -231,12 +245,8 @@ overviewRoutes.get('/overview', async (c) => { const urgentKRs = []; for (const kr of thisWeekKRs.slice(0, 20)) { const obj = allObjsMap.get(kr.objectiveId); - const owner = obj?.ownerId - ? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) }) - : null; - const proj = obj?.projectId - ? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) }) - : null; + 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')); @@ -288,22 +298,24 @@ overviewRoutes.get('/overview', async (c) => { 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 - ? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) }) - : null; - const proj = obj?.projectId - ? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) }) - : null; - - // 获取最后一条操作日志作为原因 - const lastLog = await db.select().from(krLogs) - .where(eq(krLogs.krId, kr.id)) - .orderBy(desc(krLogs.createdAt)) - .limit(1); - const reason = lastLog[0]?.detail || ''; + 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; diff --git a/backend/src/routes/roi.ts b/backend/src/routes/roi.ts index fe2ba1f..99b983a 100644 --- a/backend/src/routes/roi.ts +++ b/backend/src/routes/roi.ts @@ -23,6 +23,9 @@ import { applyAutoIdentifier } from '../services/roi/identifier-generator'; export const roiRoutes = new Hono(); +// ✱ 全局权限:所有 ROI 相关端点(含 /api/projects/:id/{tag,cost-events,...})只允许 admin +roiRoutes.use('*', requireRole('admin')); + // ────────────────────────────────────────── // 核心查询接口 // ────────────────────────────────────────── diff --git a/backend/src/services/okr.ts b/backend/src/services/okr.ts index e434b7b..b784b05 100644 --- a/backend/src/services/okr.ts +++ b/backend/src/services/okr.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { db } from '../db/index'; import { objectives, keyResults, users, projects, krLogs } from '../db/schema'; @@ -6,24 +6,56 @@ import { desc } from 'drizzle-orm'; import { AppError } from '../middleware/error-handler'; export async function getOKRByPeriod(period?: string) { + // 1. 拿 objectives(按 period 可选过滤) const allObjectives = period ? await db.select().from(objectives).where(eq(objectives.period, period)) : await db.select().from(objectives); - const result = []; - for (const obj of allObjectives) { - const krs = await db.select().from(keyResults) - .where(eq(keyResults.objectiveId, obj.id)); + if (allObjectives.length === 0) return { objectives: [] }; - const owner = obj.ownerId - ? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) }) - : null; + const objIds = allObjectives.map(o => o.id); + const ownerIds = Array.from(new Set(allObjectives.map(o => o.ownerId).filter(Boolean) as string[])); + const projectIds = Array.from(new Set(allObjectives.map(o => o.projectId).filter(Boolean) as string[])); - const project = obj.projectId - ? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) }) - : null; + // 2. 一次性批量拉 KRs / users / projects + const [allKRs, allOwners, allProjects] = await Promise.all([ + db.select().from(keyResults).where(inArray(keyResults.objectiveId, objIds)), + ownerIds.length > 0 ? db.select().from(users).where(inArray(users.id, ownerIds)) : Promise.resolve([]), + projectIds.length > 0 ? db.select().from(projects).where(inArray(projects.id, projectIds)) : Promise.resolve([]), + ]); - result.push({ + // 3. 一次性批量拉所有 KR 的 logs(只取 postponed 类型,减少传输) + const krIds = allKRs.map(k => k.id); + const allLogs = krIds.length > 0 + ? await db.select().from(krLogs) + .where(inArray(krLogs.krId, krIds)) + .orderBy(desc(krLogs.createdAt)) + : []; + + // 4. 索引化 + const krsByObj = new Map(); + for (const kr of allKRs) { + if (!krsByObj.has(kr.objectiveId)) krsByObj.set(kr.objectiveId, []); + krsByObj.get(kr.objectiveId)!.push(kr); + } + const ownerById = new Map(allOwners.map(u => [u.id, u])); + const projectById = new Map(allProjects.map(p => [p.id, p])); + + // 每个 KR 取它的"最近一条 postponed log" + const postponedByKr = new Map(); + for (const log of allLogs) { // 已按 createdAt desc 排序 + if (log.action === 'postponed' && !postponedByKr.has(log.krId)) { + postponedByKr.set(log.krId, log.detail || ''); + } + } + + // 5. 组装(纯内存,O(n)) + const result = allObjectives.map(obj => { + const krs = krsByObj.get(obj.id) || []; + const owner = obj.ownerId ? ownerById.get(obj.ownerId) : null; + const project = obj.projectId ? projectById.get(obj.projectId) : null; + + return { id: obj.id, title: obj.title, ownerName: owner?.displayName || '未指定', @@ -33,15 +65,8 @@ export async function getOKRByPeriod(period?: string) { startDate: obj.startDate || null, endDate: obj.endDate || null, progress: obj.progress || 0, - keyResults: await Promise.all(krs.map(async kr => { - // 查是否有延期记录 - const postponeLog = await db.select().from(krLogs) - .where(eq(krLogs.krId, kr.id)) - .orderBy(desc(krLogs.createdAt)) - .limit(5); - const wasPostponed = postponeLog.some(l => l.action === 'postponed'); - const lastPostponeReason = postponeLog.find(l => l.action === 'postponed')?.detail || null; - + keyResults: krs.map(kr => { + const lastPostponeReason = postponedByKr.get(kr.id) ?? null; return { id: kr.id, title: kr.title, @@ -50,7 +75,7 @@ export async function getOKRByPeriod(period?: string) { unit: kr.unit || '', weight: kr.weight || 1, status: kr.status || 'active', - wasPostponed, + wasPostponed: lastPostponeReason !== null, lastPostponeReason, startDate: kr.startDate || null, endDate: kr.endDate || null, @@ -58,9 +83,9 @@ export async function getOKRByPeriod(period?: string) { ? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100) : 0, }; - })), - }); - } + }), + }; + }); return { objectives: result }; } diff --git a/frontend/src/components/layout/AppLayout.vue b/frontend/src/components/layout/AppLayout.vue index d4a5362..e5c78fa 100644 --- a/frontend/src/components/layout/AppLayout.vue +++ b/frontend/src/components/layout/AppLayout.vue @@ -70,6 +70,7 @@ onUnmounted(() => { flex: 1; padding: var(--space-6); overflow-y: auto; + background: var(--color-bg); } /* Overlay backdrop for mobile sidebar */ diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 5bdeb6c..5a55782 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -102,8 +102,8 @@ const menuOptions = computed(() => { items.push({ label: 'Git 活动', key: '/git', icon: 'git-branch' }); } - // ROI 罗盘:仅 admin/manager - if (role === 'admin' || role === 'manager') { + // ROI 罗盘:仅 admin + if (role === 'admin') { items.push({ label: 'ROI 罗盘', key: '/roi', icon: 'trending-up' }); } @@ -276,17 +276,18 @@ const roleTagType = computed(() => { width: var(--sidebar-width); height: 100vh; background: var(--color-bg-sidebar); - color: #E5E7EB; + color: var(--color-text-onDark); display: flex; flex-direction: column; position: fixed; left: 0; top: 0; - transition: width var(--duration-collapse) var(--ease-default), - transform 0.3s ease; + transition: width var(--duration-collapse) var(--ease-out), + transform var(--duration-medium) var(--ease-out); z-index: var(--z-sticky); overflow: hidden; overflow-y: auto; + border-right: 1px solid oklch(0.25 0.012 230); } .sidebar.collapsed { @@ -334,20 +335,24 @@ const roleTagType = computed(() => { .logo-icon { width: 32px; height: 32px; - background: var(--color-primary-hex); - border-radius: var(--radius-btn); + background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-hover) 100%); + border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; - font-weight: 800; - font-size: 12px; - color: white; + font-family: var(--font-display); + font-weight: var(--weight-semibold); + font-size: 13px; + color: var(--color-text-onDark); flex-shrink: 0; + letter-spacing: -0.02em; } .logo-text { - font-weight: 700; - font-size: 16px; + font-family: var(--font-display); + font-weight: var(--weight-semibold); + font-size: var(--text-md); + letter-spacing: -0.01em; white-space: nowrap; } @@ -358,25 +363,31 @@ const roleTagType = computed(() => { } .nav-item { - padding: var(--space-3) var(--space-4); - margin-bottom: var(--space-1); - border-radius: var(--radius-btn); + padding: var(--space-2) var(--space-4); + margin-bottom: 2px; + border-radius: var(--radius-md); cursor: pointer; - transition: background var(--duration-hover) var(--ease-default); + transition: background var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); position: relative; white-space: nowrap; display: flex; align-items: center; justify-content: space-between; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: oklch(0.75 0.010 220); } .nav-item:hover { - background: rgba(255,255,255,0.08); + background: oklch(0.24 0.014 230); + color: var(--color-text-onDark); } .nav-item.active { - background: rgba(59,89,152,0.3); - border-left: 3px solid var(--color-primary-hex); + background: oklch(0.26 0.018 220); + color: var(--color-accent); + border-left: 2px solid var(--color-accent); } .nav-label { diff --git a/frontend/src/components/roi/RevenuePieChart.vue b/frontend/src/components/roi/RevenuePieChart.vue index 6ba9429..4e15ba2 100644 --- a/frontend/src/components/roi/RevenuePieChart.vue +++ b/frontend/src/components/roi/RevenuePieChart.vue @@ -7,13 +7,22 @@ const props = defineProps<{ }>(); const CATEGORY_LABELS: Record = { - cash_cow: '💰 现金牛', - efficiency_tool: '⚙️ 效能工具', - moat: '💎 资本护城河', - composite: '🚀 复合型', + cash_cow: '现金牛', + efficiency_tool: '效能工具', + moat: '资本护城河', + composite: '复合型', uncategorized: '未打标', }; +function fmtCurrency(n: number): string { + if (n >= 10000) return `¥${(n / 10000).toFixed(1)}万`; + return `¥${Math.round(n).toLocaleString()}`; +} + +const total = computed(() => + Object.values(props.byCategory).reduce((s, v) => s + (v.totalRevenue > 0 ? v.totalRevenue : 0), 0) +); + const option = computed(() => { const data = Object.entries(props.byCategory) .filter(([, v]) => v.totalRevenue > 0) @@ -21,13 +30,74 @@ const option = computed(() => { return { color: CHART_COLORS, - tooltip: { trigger: 'item', formatter: '{b}: ¥{c} ({d}%)' }, - legend: { orient: 'vertical', left: 'left', top: 'middle', textStyle: { fontSize: 12 } }, + textStyle: { fontFamily: "'Geist', 'PingFang SC', sans-serif", color: '#4d5258' }, + tooltip: { + trigger: 'item', + backgroundColor: '#ffffff', + borderColor: '#dfe2e6', + borderWidth: 1, + textStyle: { color: '#2d3033', fontSize: 12 }, + extraCssText: 'box-shadow: 0 8px 24px rgba(34,40,42,0.06); border-radius: 10px; padding: 8px 12px;', + formatter: (params: any) => ` +
${params.name}
+
¥${params.value.toLocaleString()} (${params.percent}%)
+ `, + }, + legend: { + orient: 'horizontal', + bottom: 0, + left: 'center', + itemGap: 18, + itemWidth: 8, + itemHeight: 8, + icon: 'circle', + textStyle: { fontSize: 12, color: '#4d5258' }, + }, + graphic: [ + { + type: 'text', + left: 'center', + top: '38%', + style: { + text: '总产出', + fill: '#7a8085', + fontSize: 11, + fontFamily: "'Geist', 'PingFang SC', sans-serif", + fontWeight: 500, + }, + }, + { + type: 'text', + left: 'center', + top: '46%', + style: { + text: fmtCurrency(total.value), + fill: '#2d3033', + fontSize: 22, + fontWeight: 600, + fontFamily: "'JetBrains Mono', monospace", + }, + }, + ], series: [{ type: 'pie', - radius: ['40%', '70%'], - center: ['65%', '50%'], - label: { formatter: '{b}\n{d}%' }, + radius: ['58%', '78%'], + center: ['50%', '45%'], + avoidLabelOverlap: true, + itemStyle: { + borderColor: '#ffffff', + borderWidth: 2, + }, + label: { show: false }, + labelLine: { show: false }, + emphasis: { + scale: true, + scaleSize: 6, + itemStyle: { + shadowBlur: 12, + shadowColor: 'rgba(0,0,0,0.10)', + }, + }, data, }], }; diff --git a/frontend/src/components/roi/admin/MappingPanel.vue b/frontend/src/components/roi/admin/MappingPanel.vue index da97681..2011846 100644 --- a/frontend/src/components/roi/admin/MappingPanel.vue +++ b/frontend/src/components/roi/admin/MappingPanel.vue @@ -85,14 +85,18 @@ const unmappedColumns = [ 当前映射 ({{ mappings.length }}) + 添加映射 - +
+ +
⚠️ 未映射的营收事件 ({{ unmapped.length }})
外部 API 拉到但未匹配到 DevPerf 项目的营收事件,先放在收容表里待处理。新增对应映射后,后续数据会自动归类。
- +
+ +
diff --git a/frontend/src/components/shared/DataCard.vue b/frontend/src/components/shared/DataCard.vue index 3977f7b..0bb6e29 100644 --- a/frontend/src/components/shared/DataCard.vue +++ b/frontend/src/components/shared/DataCard.vue @@ -29,35 +29,40 @@ defineProps<{ diff --git a/frontend/src/views/ProjectDetail.vue b/frontend/src/views/ProjectDetail.vue index 84ce6b7..1cecef4 100644 --- a/frontend/src/views/ProjectDetail.vue +++ b/frontend/src/views/ProjectDetail.vue @@ -332,6 +332,7 @@ function canEditObj(obj: any): boolean {

{{ data.project?.name }}

- + 💎 ROI 看板 {{ data.okr.overallProgress }}% diff --git a/frontend/src/views/ProjectList.vue b/frontend/src/views/ProjectList.vue index 127a128..49eee87 100644 --- a/frontend/src/views/ProjectList.vue +++ b/frontend/src/views/ProjectList.vue @@ -1,7 +1,7 @@ diff --git a/frontend/src/views/ProjectRoiBoard.vue b/frontend/src/views/ProjectRoiBoard.vue index b143af4..6d79657 100644 --- a/frontend/src/views/ProjectRoiBoard.vue +++ b/frontend/src/views/ProjectRoiBoard.vue @@ -128,9 +128,9 @@ function fmtPercent(n: number | null | undefined): string { } function roiColor(n: number | null | undefined): string { if (n === null || n === undefined) return 'var(--color-text-muted)'; - if (n >= 100) return '#0D9668'; - if (n >= 0) return '#D4920A'; - return '#DC2626'; + if (n >= 100) return 'var(--color-success)'; + if (n >= 0) return 'var(--color-warning)'; + return 'var(--color-error)'; } function bepDisplay(): string { const bep = lifetimeAgg.value?.bepDays; @@ -145,53 +145,68 @@ const isBepWarn = computed(() => lifetimeAgg.value?.bepDays === null); diff --git a/frontend/src/views/RoiDashboard.vue b/frontend/src/views/RoiDashboard.vue index 177bebd..dd677f2 100644 --- a/frontend/src/views/RoiDashboard.vue +++ b/frontend/src/views/RoiDashboard.vue @@ -160,49 +160,57 @@ const projectColumns = [