feat: 实现需求管理、加班记录、成员/角色管理模块
- 需求模块:完整 CRUD、状态流转(采纳/拒绝/关闭)、详情抽屉、产品→项目级联选择 - 加班记录:产品→项目→版本三级联动、月份筛选(MonthPicker)、CSV 导出 - 成员管理:左右布局(部门树+成员列表)、手机号脱敏、初始密码自动生成及规则设置 - 角色管理:卡片列表、系统角色保护、CRUD - 通用组件:FilterSelect 下拉、MonthPicker 月份选择器、Pagination 分页 - 样式统一:状态标签加 border、日期输入现代化、筛选组件风格一致 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
196
apps/web/lib/health.ts
Normal file
196
apps/web/lib/health.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user