diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 3d0c9f8..c4e4fc6 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -154,6 +154,28 @@ adminRoutes.post('/admin/projects', zValidator('json', createProjectSchema), asy return c.json({ code: 0, data: { id }, message: 'success' }, 201); }); +const updateProjectSchema = z.object({ + name: z.string().min(1).max(200).optional(), + identifier: z.string().min(1).max(20).toUpperCase().optional(), +}); + +adminRoutes.patch('/admin/projects/:id', zValidator('json', updateProjectSchema), async (c) => { + const id = c.req.param('id'); + const data = c.req.valid('json'); + + const project = await db.query.projects.findFirst({ where: eq(projects.id, id) }); + if (!project) { + throw new AppError(40402, 'Project not found', 404); + } + + const updateData: Record = { updatedAt: new Date() }; + if (data.name) updateData.name = data.name; + if (data.identifier) updateData.identifier = data.identifier; + + await db.update(projects).set(updateData).where(eq(projects.id, id)); + return c.json({ code: 0, data: { id }, message: 'success' }); +}); + adminRoutes.delete('/admin/projects/:id', async (c) => { const id = c.req.param('id'); await db.delete(projects).where(eq(projects.id, id)); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index b411098..23ec65b 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { v4 as uuid } from 'uuid'; import { db } from '../db/index'; import { projects, sprintSnapshots, milestones, taskSnapshots, gitCommits, gitPRs, users, objectives, keyResults, projectRepos, krLogs } from '../db/schema'; -import { eq, and, desc, gte } from 'drizzle-orm'; +import { eq, and, desc, gte, inArray } from 'drizzle-orm'; import { requireRole } from '../middleware/role'; import { AppError } from '../middleware/error-handler'; import { getAllowedProjectIds } from '../services/permissions'; @@ -182,32 +182,47 @@ projectRoutes.get('/projects/:id', async (c) => { })), }; - // OKR for this project + // OKR for this project (batch queries to avoid N+1) const projectObjectives = await db.select().from(objectives) .where(eq(objectives.projectId, projectId)); + const objIds = projectObjectives.map(o => o.id); + const allKRsForProject = objIds.length > 0 + ? await db.select().from(keyResults).where(inArray(keyResults.objectiveId, objIds)) + : []; + const krIds = allKRsForProject.map(kr => kr.id); + const allLogsForProject = krIds.length > 0 + ? await db.select().from(krLogs).where(inArray(krLogs.krId, krIds)).orderBy(desc(krLogs.createdAt)) + : []; + + // Group KRs by objective, logs by KR + const krsByObj = new Map(); + for (const kr of allKRsForProject) { + if (!krsByObj.has(kr.objectiveId)) krsByObj.set(kr.objectiveId, []); + krsByObj.get(kr.objectiveId)!.push(kr); + } + const logsByKR = new Map(); + for (const log of allLogsForProject) { + if (!logsByKR.has(log.krId)) logsByKR.set(log.krId, []); + const arr = logsByKR.get(log.krId)!; + if (arr.length < 5) arr.push(log); + } + const okrData = []; let totalOKRProgress = 0; for (const obj of projectObjectives) { - 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; + const krs = krsByObj.get(obj.id) || []; okrData.push({ id: obj.id, title: obj.title, ownerId: obj.ownerId || null, - ownerName: owner?.displayName || '未指定', + ownerName: obj.ownerId ? (userMap.get(obj.ownerId) || '未指定') : '未指定', period: obj.period, startDate: obj.startDate || null, endDate: obj.endDate || null, progress: obj.progress || 0, - keyResults: await Promise.all(krs.map(async kr => { - const logs = await db.select().from(krLogs) - .where(eq(krLogs.krId, kr.id)) - .orderBy(desc(krLogs.createdAt)) - .limit(5); + keyResults: krs.map(kr => { + const logs = logsByKR.get(kr.id) || []; const wasPostponed = logs.some(l => l.action === 'postponed'); const lastPostponeReason = logs.find(l => l.action === 'postponed')?.detail || null; return { @@ -226,7 +241,7 @@ projectRoutes.get('/projects/:id', async (c) => { ? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100) : 0, }; - })), + }), }); totalOKRProgress += obj.progress || 0; } @@ -247,15 +262,13 @@ projectRoutes.get('/projects/:id', async (c) => { return cleaned; })); - // 获取该项目所有 Git 数据(不限时间范围) - const allCommits = await db.select().from(gitCommits); - const allPRs = await db.select().from(gitPRs); - - const recentCommits = boundRepoNames.size > 0 - ? allCommits.filter(c => boundRepoNames.has(c.repoName)) + // 获取该项目绑定仓库的 Git 数据(按仓库名过滤,避免全表扫描) + const boundRepoNamesList = Array.from(boundRepoNames); + const recentCommits = boundRepoNamesList.length > 0 + ? await db.select().from(gitCommits).where(inArray(gitCommits.repoName, boundRepoNamesList)) : []; - const recentPRs = boundRepoNames.size > 0 - ? allPRs.filter(p => boundRepoNames.has(p.repoName)) + const recentPRs = boundRepoNamesList.length > 0 + ? await db.select().from(gitPRs).where(inArray(gitPRs.repoName, boundRepoNamesList)) : []; const weeklyTrend: { weekStart: string; commits: number; prs: number }[] = []; diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index d667b34..b7d895d 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -37,6 +37,10 @@ export function createProjectApi(data: { name: string; identifier: string }) { return request.post('/api/projects', data); } +export function updateProjectApi(id: string, data: { name?: string; identifier?: string }) { + return request.patch(`/api/admin/projects/${id}`, data); +} + export function deleteProjectApi(id: string) { return request.delete(`/api/projects/${id}`); } diff --git a/frontend/src/components/charts/ProjectProgressBars.vue b/frontend/src/components/charts/ProjectProgressBars.vue index 43b308e..85ddff8 100644 --- a/frontend/src/components/charts/ProjectProgressBars.vue +++ b/frontend/src/components/charts/ProjectProgressBars.vue @@ -18,7 +18,7 @@ const chartOptions = computed(() => { const sorted = [...props.projects].sort( (a, b) => b.currentCycleProgress - a.currentCycleProgress, ); - const names = sorted.map((p) => `${p.identifier} ${p.name}`.trim()); + const names = sorted.map((p) => p.name); const values = sorted.map((p) => p.currentCycleProgress); const bgValues = sorted.map(() => 100); @@ -31,7 +31,7 @@ const chartOptions = computed(() => { const project = sorted[idx]; if (!project) return ''; return ` - ${project.identifier} ${project.name}
+ ${project.name}
进度: ${project.currentCycleProgress}%
${project.completedPoints}/${project.totalPoints} 点 `; diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 395f481..cd975e8 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -170,7 +170,7 @@ const roleTagType = computed(() => { :class="{ active: route.path === `/projects/${proj.projectId}` }" @click="handleProjectSelect(proj.projectId)" > - {{ proj.identifier || '' }} {{ proj.name }} + {{ proj.name }} + + + + + + + + + + + +