zyc 58fe2b1ea8 feat: OKR看板重设计 + 周切换 + 恢复选日期 + 项目列表优化
- OKR看板:按项目分组大卡片 + 目标/任务折叠展开 + 统计栏 + 按项目筛选
- KR状态文本标签(已取消/已暂停/已延期)替代emoji
- 本周关键结果支持前后周切换(‹ 本周 ›)+ 日期范围显示
- 恢复暂停任务时必须选择新截止日期
- 项目列表仓库名只显示短名不显示完整URL
- 项目详情标题合并到进度条卡片内

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

339 lines
12 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 { eq } from 'drizzle-orm';
import { v4 as uuid } from 'uuid';
import { db } from '../db/index';
import { objectives, keyResults, users, projects, krLogs } from '../db/schema';
import { desc } from 'drizzle-orm';
import { AppError } from '../middleware/error-handler';
export async function getOKRByPeriod(period?: string) {
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));
const owner = obj.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
const project = obj.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
result.push({
id: obj.id,
title: obj.title,
ownerName: owner?.displayName || '未指定',
projectName: project?.name || '未关联项目',
period: obj.period,
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;
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,
};
})),
});
}
return { objectives: result };
}
/**
* 从日期自动推算所属季度,例如 2026-04-15 → "2026-Q2"
*/
function dateToPeriod(dateStr: string): string {
const d = new Date(dateStr);
const year = d.getFullYear();
const q = Math.ceil((d.getMonth() + 1) / 3);
return `${year}-Q${q}`;
}
export async function createObjective(data: {
title: string;
ownerId: string;
projectId: string;
startDate: string;
endDate: string;
period?: string;
}) {
const id = uuid();
const now = new Date();
const period = data.period || dateToPeriod(data.endDate);
await db.insert(objectives).values({
id,
title: data.title,
ownerId: data.ownerId,
projectId: data.projectId,
period,
startDate: data.startDate,
endDate: data.endDate,
progress: 0,
createdAt: now,
updatedAt: now,
});
return { id };
}
export async function createKeyResult(objectiveId: string, data: {
title: string;
targetValue: number;
unit: string;
weight: number;
startDate?: string;
endDate?: string;
linkedPlaneCycleId?: string;
linkedPlaneModuleId?: string;
}) {
const objective = await db.query.objectives.findFirst({
where: eq(objectives.id, objectiveId),
});
if (!objective) {
throw new AppError(40403, 'Objective not found', 404);
}
const id = uuid();
const now = new Date();
await db.insert(keyResults).values({
id,
objectiveId,
title: data.title,
targetValue: data.targetValue,
currentValue: 0,
unit: data.unit,
weight: data.weight,
startDate: data.startDate || null,
endDate: data.endDate || null,
linkedPlaneCycleId: data.linkedPlaneCycleId || null,
linkedPlaneModuleId: data.linkedPlaneModuleId || null,
createdAt: now,
updatedAt: now,
});
// 自动更新目标的时间范围(取所有任务的最早开始 ~ 最晚截止)
await recalcObjectiveDates(objectiveId);
return { id };
}
/**
* 根据所有 KR 的日期自动更新 Objective 的 startDate / endDate / period
*/
async function recalcObjectiveDates(objectiveId: string) {
const krs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, objectiveId));
const starts = krs.map(kr => kr.startDate).filter(Boolean) as string[];
const ends = krs.map(kr => kr.endDate).filter(Boolean) as string[];
if (starts.length === 0 && ends.length === 0) return;
const earliest = starts.length > 0 ? starts.sort()[0] : null;
const latest = ends.length > 0 ? ends.sort().reverse()[0] : null;
const period = latest ? dateToPeriod(latest) : undefined;
const updateData: Record<string, any> = { updatedAt: new Date() };
if (earliest) updateData.startDate = earliest;
if (latest) updateData.endDate = latest;
if (period) updateData.period = period;
await db.update(objectives).set(updateData).where(eq(objectives.id, objectiveId));
}
export async function updateKeyResultProgress(krId: string, currentValue: number) {
const kr = await db.query.keyResults.findFirst({
where: eq(keyResults.id, krId),
});
if (!kr) {
throw new AppError(40404, 'Key result not found', 404);
}
await db.update(keyResults)
.set({ currentValue, updatedAt: new Date() })
.where(eq(keyResults.id, krId));
// Recalculate objective progress (weighted average)
const allKRs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, kr.objectiveId));
const totalWeight = allKRs.reduce((sum, k) => sum + (k.weight || 1), 0);
const weightedProgress = allKRs.reduce((sum, k) => {
const val = k.id === krId ? currentValue : (k.currentValue || 0);
const progress = k.targetValue > 0 ? (val / k.targetValue) * 100 : 0;
return sum + progress * (k.weight || 1);
}, 0);
const objectiveProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0;
await db.update(objectives)
.set({ progress: objectiveProgress, updatedAt: new Date() })
.where(eq(objectives.id, kr.objectiveId));
return {
id: krId,
progress: kr.targetValue > 0 ? Math.round((currentValue / kr.targetValue) * 100) : 0,
objectiveProgress,
};
}
export async function deleteObjective(id: string) {
// Delete all KRs first
await db.delete(keyResults).where(eq(keyResults.objectiveId, id));
await db.delete(objectives).where(eq(objectives.id, id));
}
export async function deleteKeyResult(id: string) {
const kr = await db.query.keyResults.findFirst({
where: eq(keyResults.id, id),
});
if (!kr) {
throw new AppError(40404, 'Key result not found', 404);
}
await db.delete(keyResults).where(eq(keyResults.id, id));
// Recalculate objective progress
const remainingKRs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, kr.objectiveId));
if (remainingKRs.length === 0) {
await db.update(objectives)
.set({ progress: 0, updatedAt: new Date() })
.where(eq(objectives.id, kr.objectiveId));
} else {
const totalWeight = remainingKRs.reduce((sum, k) => sum + (k.weight || 1), 0);
const weightedProgress = remainingKRs.reduce((sum, k) => {
const progress = k.targetValue > 0 ? ((k.currentValue || 0) / k.targetValue) * 100 : 0;
return sum + progress * (k.weight || 1);
}, 0);
const objectiveProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0;
await db.update(objectives)
.set({ progress: objectiveProgress, updatedAt: new Date() })
.where(eq(objectives.id, kr.objectiveId));
}
// 重算目标时间范围
await recalcObjectiveDates(kr.objectiveId);
}
// ── 操作日志辅助 ──
async function addKRLog(krId: string, action: string, detail: string | null, operatorId: string, operatorName: string) {
await db.insert(krLogs).values({
id: uuid(),
krId,
action,
detail,
operatorId,
operatorName,
createdAt: new Date(),
});
}
async function recalcObjectiveProgress(objectiveId: string) {
const allKRs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, objectiveId));
// 只算 active 和已完成的 KR暂停和取消的不计入
const activeKRs = allKRs.filter(kr => kr.status !== 'paused' && kr.status !== 'cancelled');
const totalWeight = activeKRs.reduce((sum, k) => sum + (k.weight || 1), 0);
const weightedProgress = activeKRs.reduce((sum, k) => {
const progress = k.targetValue > 0 ? ((k.currentValue || 0) / k.targetValue) * 100 : 0;
return sum + progress * (k.weight || 1);
}, 0);
const objProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0;
await db.update(objectives).set({ progress: objProgress, updatedAt: new Date() })
.where(eq(objectives.id, objectiveId));
}
// ── 延期 ──
export async function postponeKR(krId: string, newEndDate: string, reason: string, operatorId: string, operatorName: string) {
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
if (!kr) throw new AppError(40404, 'Key result not found', 404);
const oldEndDate = kr.endDate || '未设置';
await db.update(keyResults).set({ endDate: newEndDate, updatedAt: new Date() })
.where(eq(keyResults.id, krId));
await addKRLog(krId, 'postponed', `截止日期 ${oldEndDate}${newEndDate},原因:${reason}`, operatorId, operatorName);
await recalcObjectiveDates(kr.objectiveId);
return { id: krId, endDate: newEndDate };
}
// ── 暂停 ──
export async function pauseKR(krId: string, reason: string, operatorId: string, operatorName: string) {
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
if (!kr) throw new AppError(40404, 'Key result not found', 404);
await db.update(keyResults).set({ status: 'paused', updatedAt: new Date() })
.where(eq(keyResults.id, krId));
await addKRLog(krId, 'paused', `原因:${reason}`, operatorId, operatorName);
await recalcObjectiveProgress(kr.objectiveId);
return { id: krId, status: 'paused' };
}
// ── 恢复 ──
export async function resumeKR(krId: string, newEndDate: string, operatorId: string, operatorName: string) {
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
if (!kr) throw new AppError(40404, 'Key result not found', 404);
const oldEndDate = kr.endDate || '未设置';
await db.update(keyResults).set({ status: 'active', endDate: newEndDate, updatedAt: new Date() })
.where(eq(keyResults.id, krId));
await addKRLog(krId, 'resumed', `恢复为进行中,截止日期 ${oldEndDate}${newEndDate}`, operatorId, operatorName);
await recalcObjectiveProgress(kr.objectiveId);
await recalcObjectiveDates(kr.objectiveId);
return { id: krId, status: 'active', endDate: newEndDate };
}
// ── 取消 ──
export async function cancelKR(krId: string, reason: string, operatorId: string, operatorName: string) {
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
if (!kr) throw new AppError(40404, 'Key result not found', 404);
await db.update(keyResults).set({ status: 'cancelled', updatedAt: new Date() })
.where(eq(keyResults.id, krId));
await addKRLog(krId, 'cancelled', `原因:${reason}`, operatorId, operatorName);
await recalcObjectiveProgress(kr.objectiveId);
return { id: krId, status: 'cancelled' };
}
// ── 获取操作日志 ──
export async function getKRLogs(krId: string) {
return await db.select().from(krLogs)
.where(eq(krLogs.krId, krId))
.orderBy(desc(krLogs.createdAt));
}