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:
Script Generator
2026-06-09 18:16:18 +08:00
parent 24ba61f929
commit 9a0b16a8f1
36 changed files with 4355 additions and 272 deletions

196
apps/web/lib/health.ts Normal file
View 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;
}