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 = { 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 = { healthy: '健康', good: '良好', attention: '关注', risk: '风险', critical: '严重', }; export const HEALTH_LEVEL_COLOR: Record = { 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 = { 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 = { 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 = { critical: 0, high: 1, medium: 2 }; tags.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); return tags; }