Files
ftb-project-management/apps/web/lib/health.ts
Script Generator 9a0b16a8f1 feat: 实现需求管理、加班记录、成员/角色管理模块
- 需求模块:完整 CRUD、状态流转(采纳/拒绝/关闭)、详情抽屉、产品→项目级联选择
- 加班记录:产品→项目→版本三级联动、月份筛选(MonthPicker)、CSV 导出
- 成员管理:左右布局(部门树+成员列表)、手机号脱敏、初始密码自动生成及规则设置
- 角色管理:卡片列表、系统角色保护、CRUD
- 通用组件:FilterSelect 下拉、MonthPicker 月份选择器、Pagination 分页
- 样式统一:状态标签加 border、日期输入现代化、筛选组件风格一致

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 18:16:18 +08:00

197 lines
7.0 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 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'],
integration: ['frontend', 'backend'],
testing: ['testing'],
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;
}