devperf/backend/src/routes/projects.ts
zyc 512d3baca2
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
feat: 开发者可编辑项目、侧边栏项目列表优化、筛选器UI改进
- 新增 PATCH /api/projects/:id 开发者有权限可编辑项目
- 侧边栏项目列表改用项目API直接拉取,路由切换时自动刷新
- 项目筛选器和权限分配下拉框只显示项目名称,标签自动折叠

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:02:42 +08:00

389 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 { zValidator } from '@hono/zod-validator';
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, userProjectPermissions } from '../db/schema';
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';
import dayjs from 'dayjs';
export const projectRoutes = new Hono();
// GET /api/projects — 所有登录用户都能查(观察者仅返回已分配的项目)
projectRoutes.get('/projects', async (c) => {
const user = c.get('user');
const allowedIds = await getAllowedProjectIds(user);
let allProjects = await db.select().from(projects);
if (allowedIds !== null) {
allProjects = allProjects.filter(p => allowedIds.includes(p.id));
}
return c.json({
code: 0,
data: allProjects.map(p => ({
id: p.id,
name: p.name,
identifier: p.identifier,
planeProjectId: p.planeProjectId,
createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : p.createdAt,
lastSyncedAt: p.lastSyncedAt?.toISOString() || null,
})),
message: 'success',
});
});
// POST /api/projects — admin/manager/developer 都能创建
const createProjectSchema = z.object({
name: z.string().min(1).max(200),
identifier: z.string().min(1).max(20).toUpperCase(),
});
projectRoutes.post('/projects',
requireRole('admin', 'manager', 'developer'),
zValidator('json', createProjectSchema),
async (c) => {
const user = c.get('user');
const data = c.req.valid('json');
const id = uuid();
const now = new Date();
await db.insert(projects).values({
id,
planeProjectId: `local-${id}`,
name: data.name,
identifier: data.identifier,
createdAt: now,
updatedAt: now,
});
// 开发者创建项目时自动获得该项目的查看权限
if (user.role === 'developer') {
await db.insert(userProjectPermissions).values({
id: uuid(),
userId: user.sub,
projectId: id,
createdAt: now,
});
}
return c.json({ code: 0, data: { id }, message: 'success' }, 201);
}
);
// DELETE /api/projects/:id — 仅 admin 能删
projectRoutes.delete('/projects/:id',
requireRole('admin'),
async (c) => {
const id = c.req.param('id');
// 先清理关联数据(外键约束)
await db.delete(userProjectPermissions).where(eq(userProjectPermissions.projectId, id));
await db.delete(projectRepos).where(eq(projectRepos.projectId, id));
// 清理 OKRKR logs → KR → Objectives
const objs = await db.select().from(objectives).where(eq(objectives.projectId, id));
for (const obj of objs) {
const krs = await db.select().from(keyResults).where(eq(keyResults.objectiveId, obj.id));
for (const kr of krs) {
await db.delete(krLogs).where(eq(krLogs.krId, kr.id));
}
await db.delete(keyResults).where(eq(keyResults.objectiveId, obj.id));
}
await db.delete(objectives).where(eq(objectives.projectId, id));
await db.delete(projects).where(eq(projects.id, id));
return c.json({ code: 0, data: null, message: 'success' });
}
);
// PATCH /api/projects/:id — 开发者(有权限)或管理员可编辑
const updateProjectSchema = z.object({
name: z.string().min(1).max(200).optional(),
identifier: z.string().min(1).max(20).toUpperCase().optional(),
});
projectRoutes.patch('/projects/:id',
requireRole('admin', 'manager', 'developer'),
zValidator('json', updateProjectSchema),
async (c) => {
const id = c.req.param('id');
const user = c.get('user');
const data = c.req.valid('json');
// 开发者需要有该项目的权限
if (user.role === 'developer') {
const allowedIds = await getAllowedProjectIds(user);
if (allowedIds !== null && !allowedIds.includes(id)) {
throw new AppError(40103, 'Insufficient permissions', 403);
}
}
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<string, any> = { 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' });
}
);
// GET /api/projects/:id/repos — 所有登录用户
projectRoutes.get('/projects/:id/repos', async (c) => {
const projectId = c.req.param('id');
const bindings = await db.select().from(projectRepos)
.where(eq(projectRepos.projectId, projectId));
return c.json({ code: 0, data: bindings, message: 'success' });
});
// POST /api/projects/:id/repos — admin/manager/developer 能绑定仓库
const bindRepoSchema = z.object({ repoName: z.string().min(1) });
projectRoutes.post('/projects/:id/repos',
requireRole('admin', 'manager', 'developer'),
zValidator('json', bindRepoSchema),
async (c) => {
const projectId = c.req.param('id');
const { repoName } = c.req.valid('json');
const id = uuid();
await db.insert(projectRepos).values({ id, projectId, repoName, createdAt: new Date() });
return c.json({ code: 0, data: { id }, message: 'success' }, 201);
}
);
// DELETE /api/project-repos/:id — admin/manager 能解绑
projectRoutes.delete('/project-repos/:id',
requireRole('admin', 'manager'),
async (c) => {
const id = c.req.param('id');
await db.delete(projectRepos).where(eq(projectRepos.id, id));
return c.json({ code: 0, data: null, message: 'success' });
}
);
// GET /api/projects/:id
projectRoutes.get('/projects/:id', async (c) => {
const projectId = c.req.param('id');
const user = c.get('user');
// 观察者只能查看已分配的项目
const allowedIds = await getAllowedProjectIds(user);
if (allowedIds !== null && !allowedIds.includes(projectId)) {
throw new AppError(40103, 'Insufficient permissions', 403);
}
const project = await db.query.projects.findFirst({
where: eq(projects.id, projectId),
});
if (!project) {
throw new AppError(40402, 'Project not found', 404);
}
// Current cycle
const activeSprint = await db.query.sprintSnapshots.findFirst({
where: and(
eq(sprintSnapshots.projectId, projectId),
eq(sprintSnapshots.status, 'active')
),
});
const currentCycle = activeSprint ? {
name: activeSprint.name,
startDate: activeSprint.startDate || '',
endDate: activeSprint.endDate || '',
deliveryRate: (activeSprint.totalPoints || 0) > 0
? Math.round(((activeSprint.completedPoints || 0) / (activeSprint.totalPoints || 1)) * 100)
: 0,
burndown: (activeSprint.burndownData as any[]) || [],
} : null;
// Milestones
const projectMilestones = await db.select().from(milestones)
.where(eq(milestones.projectId, projectId));
const milestoneData = projectMilestones.map(m => ({
id: m.id,
name: m.name,
status: m.status || 'backlog',
targetDate: m.targetDate || '',
progress: (m.totalIssues || 0) > 0
? Math.round(((m.completedIssues || 0) / (m.totalIssues || 1)) * 100)
: 0,
totalIssues: m.totalIssues || 0,
completedIssues: m.completedIssues || 0,
}));
// Task Matrix
const tasks = await db.select().from(taskSnapshots)
.where(eq(taskSnapshots.projectId, projectId));
const memberTaskMap: Record<string, { todo: number; inProgress: number; review: number; done: number; totalPoints: number; name: string }> = {};
const allUsers = await db.select().from(users);
const userMap = new Map(allUsers.map(u => [u.id, u.displayName]));
for (const task of tasks) {
const uid = task.assigneeId || 'unassigned';
if (!memberTaskMap[uid]) {
memberTaskMap[uid] = { todo: 0, inProgress: 0, review: 0, done: 0, totalPoints: 0, name: userMap.get(uid) || 'Unassigned' };
}
const m = memberTaskMap[uid];
if (task.status === 'todo') m.todo++;
else if (task.status === 'in_progress') m.inProgress++;
else if (task.status === 'review') m.review++;
else if (task.status === 'done') m.done++;
m.totalPoints += task.storyPoints || 0;
}
const taskMatrix = {
members: Object.entries(memberTaskMap).map(([userId, data]) => ({
userId,
...data,
})),
};
// 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<string, typeof allKRsForProject>();
for (const kr of allKRsForProject) {
if (!krsByObj.has(kr.objectiveId)) krsByObj.set(kr.objectiveId, []);
krsByObj.get(kr.objectiveId)!.push(kr);
}
const logsByKR = new Map<string, typeof allLogsForProject>();
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 = krsByObj.get(obj.id) || [];
okrData.push({
id: obj.id,
title: obj.title,
ownerId: obj.ownerId || null,
ownerName: obj.ownerId ? (userMap.get(obj.ownerId) || '未指定') : '未指定',
period: obj.period,
startDate: obj.startDate || null,
endDate: obj.endDate || null,
progress: obj.progress || 0,
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 {
id: kr.id,
title: kr.title,
targetValue: kr.targetValue,
currentValue: kr.currentValue || 0,
unit: kr.unit || '',
weight: kr.weight || 1,
status: kr.status || 'active',
wasPostponed,
lastPostponeReason,
startDate: kr.startDate || null,
endDate: kr.endDate || null,
progress: kr.targetValue > 0
? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100)
: 0,
};
}),
});
totalOKRProgress += obj.progress || 0;
}
const avgOKRProgress = projectObjectives.length > 0
? Math.round(totalOKRProgress / projectObjectives.length)
: 0;
// Git Activity for project (filter by bound repos)
const boundRepos = await db.select().from(projectRepos)
.where(eq(projectRepos.projectId, projectId));
// 解析仓库名:支持完整 URL、owner/name、纯名称
const boundRepoNames = new Set(boundRepos.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;
}));
// 获取该项目绑定仓库的 Git 数据(按仓库名过滤,避免全表扫描)
const boundRepoNamesList = Array.from(boundRepoNames);
const recentCommits = boundRepoNamesList.length > 0
? await db.select().from(gitCommits).where(inArray(gitCommits.repoName, boundRepoNamesList))
: [];
const recentPRs = boundRepoNamesList.length > 0
? await db.select().from(gitPRs).where(inArray(gitPRs.repoName, boundRepoNamesList))
: [];
const weeklyTrend: { weekStart: string; commits: number; prs: number }[] = [];
for (let i = 0; i < 12; i++) {
const weekStart = dayjs().subtract(11 - i, 'week').startOf('week');
const weekEnd = weekStart.add(7, 'day');
weeklyTrend.push({
weekStart: weekStart.format('YYYY-MM-DD'),
commits: recentCommits.filter(c => {
const d = dayjs(c.committedAt);
return d.isAfter(weekStart) && d.isBefore(weekEnd);
}).length,
prs: recentPRs.filter(p => {
const d = dayjs(p.createdAt);
return d.isAfter(weekStart) && d.isBefore(weekEnd);
}).length,
});
}
const gitActivity = {
recentCommits: recentCommits.length,
recentPRs: recentPRs.length,
weeklyTrend,
boundRepos: Array.from(boundRepoNames),
};
return c.json({
code: 0,
data: {
project: {
id: project.id,
name: project.name,
identifier: project.identifier,
lastSyncedAt: project.lastSyncedAt?.toISOString() || null,
},
currentCycle,
milestones: milestoneData,
taskMatrix,
gitActivity,
recentCommitList: recentCommits
.sort((a, b) => dayjs(b.committedAt).valueOf() - dayjs(a.committedAt).valueOf())
.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,
committedAt: c.committedAt instanceof Date ? c.committedAt.toISOString() : c.committedAt,
})),
okr: {
objectives: okrData,
overallProgress: avgOKRProgress,
},
},
message: 'success',
});
});