- Stage 类型去掉 integration,新增 bug - 胶囊条:调研 → 产品设计 → UI设计 → 开发 → 测试 → BUG → 上线 - BUG 阶段进度 = 已关闭Bug / 总Bug - 全部关闭 = done(绿色),有Bug未关闭 = active(蓝色) - 版本列表/项目详情的阶段筛选同步去掉联调 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
7.0 KiB
TypeScript
197 lines
7.0 KiB
TypeScript
import type { RoleProgress } from './derive';
|
||
import type { Stage, Role } from './stage';
|
||
import { calcDelayStatus, calcOverallProgress } from './risk';
|
||
|
||
export interface RiskTag {
|
||
key: string;
|
||
label: string;
|
||
severity: 'critical' | 'high' | 'medium';
|
||
reason?: string;
|
||
suggestion?: string;
|
||
}
|
||
|
||
export type HealthLevel = 'healthy' | 'good' | 'attention' | 'risk' | 'critical';
|
||
|
||
const STAGE_ROLE_MAP: Record<string, Role[]> = {
|
||
requirement: ['product'],
|
||
product_design: ['product'],
|
||
ui_design: ['ui'],
|
||
dev: ['frontend', 'backend'],
|
||
testing: ['testing'],
|
||
bug: [],
|
||
released: [],
|
||
};
|
||
|
||
export function calcHealthScore(
|
||
status: string,
|
||
startDate?: string | null,
|
||
expectedReleaseDate?: string | null,
|
||
progress?: RoleProgress[],
|
||
): number {
|
||
if (status === 'released' || status === 'closed') return 100;
|
||
if (status === 'planned') return 100;
|
||
|
||
let score = 100;
|
||
|
||
// 暂停扣分
|
||
if (status === 'paused') score -= 20;
|
||
|
||
// 进度落后扣分
|
||
if (startDate && expectedReleaseDate) {
|
||
const now = new Date();
|
||
now.setHours(0, 0, 0, 0);
|
||
const start = new Date(startDate);
|
||
start.setHours(0, 0, 0, 0);
|
||
const end = new Date(expectedReleaseDate);
|
||
end.setHours(0, 0, 0, 0);
|
||
|
||
const totalDuration = end.getTime() - start.getTime();
|
||
if (totalDuration > 0) {
|
||
const elapsed = now.getTime() - start.getTime();
|
||
const timeProgress = Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));
|
||
const actualProgress = calcOverallProgress(progress);
|
||
const gap = Math.max(0, timeProgress - actualProgress);
|
||
score -= gap * 0.8;
|
||
}
|
||
}
|
||
|
||
// 延期扣分
|
||
if (expectedReleaseDate && status !== 'released' && status !== 'closed') {
|
||
const now = new Date();
|
||
now.setHours(0, 0, 0, 0);
|
||
const deadline = new Date(expectedReleaseDate);
|
||
deadline.setHours(0, 0, 0, 0);
|
||
const diffDays = (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||
|
||
if (diffDays < 0) {
|
||
score -= Math.min(40, Math.abs(diffDays) * 5);
|
||
} else if (diffDays <= 3) {
|
||
score -= 15;
|
||
}
|
||
}
|
||
|
||
return Math.max(0, Math.min(100, Math.round(score)));
|
||
}
|
||
|
||
export function getHealthLevel(score: number): HealthLevel {
|
||
if (score >= 90) return 'healthy';
|
||
if (score >= 75) return 'good';
|
||
if (score >= 60) return 'attention';
|
||
if (score >= 35) return 'risk';
|
||
return 'critical';
|
||
}
|
||
|
||
export const HEALTH_LEVEL_LABEL: Record<HealthLevel, string> = {
|
||
healthy: '健康',
|
||
good: '良好',
|
||
attention: '关注',
|
||
risk: '风险',
|
||
critical: '严重',
|
||
};
|
||
|
||
export const HEALTH_LEVEL_COLOR: Record<HealthLevel, string> = {
|
||
healthy: 'text-emerald-600',
|
||
good: 'text-blue-600',
|
||
attention: 'text-amber-600',
|
||
risk: 'text-orange-600',
|
||
critical: 'text-red-600',
|
||
};
|
||
|
||
export const HEALTH_LEVEL_DOT: Record<HealthLevel, string> = {
|
||
healthy: 'bg-emerald-500',
|
||
good: 'bg-blue-500',
|
||
attention: 'bg-amber-500',
|
||
risk: 'bg-orange-500',
|
||
critical: 'bg-red-500',
|
||
};
|
||
|
||
const TAG_SEVERITY_STYLE: Record<string, string> = {
|
||
critical: 'bg-red-50 text-red-600 border-red-200',
|
||
high: 'bg-orange-50 text-orange-600 border-orange-200',
|
||
medium: 'bg-amber-50 text-amber-600 border-amber-200',
|
||
};
|
||
|
||
export function getTagStyle(severity: string): string {
|
||
return TAG_SEVERITY_STYLE[severity] || TAG_SEVERITY_STYLE.medium;
|
||
}
|
||
|
||
export function calcRiskTags(
|
||
status: string,
|
||
startDate?: string | null,
|
||
expectedReleaseDate?: string | null,
|
||
progress?: RoleProgress[],
|
||
currentStage?: Stage,
|
||
members?: { role: Role; name: string }[],
|
||
): RiskTag[] {
|
||
if (status === 'released' || status === 'closed' || status === 'planned') return [];
|
||
|
||
const tags: RiskTag[] = [];
|
||
|
||
// 延期类
|
||
if (expectedReleaseDate) {
|
||
const now = new Date();
|
||
now.setHours(0, 0, 0, 0);
|
||
const deadline = new Date(expectedReleaseDate);
|
||
deadline.setHours(0, 0, 0, 0);
|
||
const diffDays = (now.getTime() - deadline.getTime()) / (1000 * 60 * 60 * 24);
|
||
|
||
if (diffDays > 7) {
|
||
tags.push({ key: 'severe_delay', label: '严重延期', severity: 'critical', reason: `已超过截止日期 ${Math.round(diffDays)} 天`, suggestion: '立即与团队确认是否调整截止日期,评估是否需要缩减版本范围' });
|
||
} else if (diffDays > 0) {
|
||
tags.push({ key: 'delay_risk', label: '延期风险', severity: 'high', reason: `已超过截止日期 ${Math.round(diffDays)} 天`, suggestion: '排查阻塞原因,与相关负责人沟通加速或调整排期' });
|
||
} else if (diffDays > -3) {
|
||
tags.push({ key: 'delay_risk', label: '延期风险', severity: 'medium', reason: `距离截止日期仅剩 ${Math.round(-diffDays)} 天`, suggestion: '关注剩余工作量是否可按时完成,提前预警相关方' });
|
||
}
|
||
}
|
||
|
||
// 进度落后
|
||
if (startDate && expectedReleaseDate) {
|
||
const now = new Date();
|
||
now.setHours(0, 0, 0, 0);
|
||
const start = new Date(startDate);
|
||
start.setHours(0, 0, 0, 0);
|
||
const end = new Date(expectedReleaseDate);
|
||
end.setHours(0, 0, 0, 0);
|
||
|
||
const totalDuration = end.getTime() - start.getTime();
|
||
if (totalDuration > 0) {
|
||
const elapsed = now.getTime() - start.getTime();
|
||
const timeProgress = Math.min(100, Math.max(0, Math.round((elapsed / totalDuration) * 100)));
|
||
const actualProgress = calcOverallProgress(progress);
|
||
const gap = timeProgress - actualProgress;
|
||
|
||
if (gap >= 30) {
|
||
tags.push({ key: 'progress_behind', label: '进度落后', severity: 'high', reason: `整体进度 ${actualProgress}%,时间已过 ${timeProgress}%,落后 ${Math.round(gap)}%`, suggestion: '排查阻塞点,考虑增加资源投入或调整版本优先级' });
|
||
}
|
||
}
|
||
}
|
||
|
||
// 阶段超时
|
||
if (currentStage && progress) {
|
||
const roles = STAGE_ROLE_MAP[currentStage] || [];
|
||
const stageItems = roles.map((r) => progress.find((p) => p.role === r)).filter(Boolean);
|
||
const maxDays = stageItems.length > 0 ? Math.max(...stageItems.map((i) => i!.daysSpent)) : 0;
|
||
if (maxDays > 14) {
|
||
tags.push({ key: 'stage_timeout', label: '阶段超时', severity: 'medium', reason: `当前阶段已耗时 ${maxDays} 天,超过经验值(14天)`, suggestion: '确认是否存在技术难点或外部依赖阻塞,及时同步风险' });
|
||
}
|
||
}
|
||
|
||
// 资源类
|
||
if (!members || members.length < 2) {
|
||
tags.push({ key: 'missing_owner', label: '负责人缺失', severity: 'high', reason: '版本参与人员不足 2 人', suggestion: '尽快分配各角色负责人,确保版本推进有明确责任人' });
|
||
} else if (currentStage) {
|
||
const neededRoles = STAGE_ROLE_MAP[currentStage] || [];
|
||
const memberRoles = new Set(members.map((m) => m.role));
|
||
const missing = neededRoles.filter((r) => !memberRoles.has(r));
|
||
if (missing.length > 0) {
|
||
tags.push({ key: 'key_role_missing', label: '关键角色缺失', severity: 'medium', reason: `当前阶段缺少对应角色人员`, suggestion: '补充缺失角色的负责人,避免阶段推进受阻' });
|
||
}
|
||
}
|
||
|
||
// 按严重程度排序
|
||
const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2 };
|
||
tags.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
||
|
||
return tags;
|
||
}
|