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

View File

@@ -1,17 +1,25 @@
import { VersionStatus } from './version-status';
import type { Stage, Role } from './stage';
interface VersionMember {
export interface VersionMember {
role: Role;
name: string;
}
interface RoleProgress {
export interface RoleProgress {
role: Role;
percent: number;
daysSpent: number;
}
export type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
export interface VersionLinks {
research?: string;
prototype?: string;
ui?: string;
}
interface ProductOverviewLike {
id: string;
name: string;
@@ -20,6 +28,7 @@ interface ProductOverviewLike {
id: string; name: string; status?: string; releaseDate: string | null; createdAt: string;
currentStage?: Stage; startDate?: string | null; expectedReleaseDate?: string | null;
members?: VersionMember[]; progress?: RoleProgress[];
priority?: Priority; links?: VersionLinks;
}[];
}
@@ -41,12 +50,15 @@ export interface VersionWithContext {
createdAt: string;
productId: string;
productName: string;
projectId: string;
projectName: string;
currentStage?: Stage;
startDate?: string | null;
expectedReleaseDate?: string | null;
members?: VersionMember[];
progress?: RoleProgress[];
priority?: Priority;
links?: VersionLinks;
}
export function flattenProjects(overview: ProductOverviewLike[]): ProjectWithContext[] {
@@ -60,6 +72,7 @@ export function flattenProjects(overview: ProductOverviewLike[]): ProjectWithCon
status: (v.status || 'released') as VersionStatus,
productId: product.id,
productName: product.name,
projectId: project.id,
projectName: project.name,
}));
result.push({
@@ -85,6 +98,7 @@ export function flattenVersions(overview: ProductOverviewLike[]): VersionWithCon
status: (version.status || 'released') as VersionStatus,
productId: product.id,
productName: product.name,
projectId: project?.id || '',
projectName: project?.name || '未关联',
});
}
@@ -96,3 +110,8 @@ export function getProjectDetail(overview: ProductOverviewLike[], projectId: str
const projects = flattenProjects(overview);
return projects.find((p) => p.id === projectId) || null;
}
export function getVersionDetail(overview: ProductOverviewLike[], versionId: string): VersionWithContext | null {
const versions = flattenVersions(overview);
return versions.find((v) => v.id === versionId) || null;
}

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;
}

65
apps/web/lib/members.ts Normal file
View File

@@ -0,0 +1,65 @@
export interface Department {
id: string;
name: string;
parentId?: string;
order: number;
createdAt: string;
}
export interface Member {
id: string;
name: string;
departmentId: string;
roleId: string;
phone: string;
email: string;
password: string;
createdAt: string;
}
export interface RoleItem {
id: string;
name: string;
description?: string;
createdAt: string;
isSystem?: boolean;
}
export interface PasswordRule {
prefix: string;
length: number;
includeUppercase: boolean;
includeLowercase: boolean;
includeNumbers: boolean;
includeSpecial: boolean;
}
export const DEFAULT_PASSWORD_RULE: PasswordRule = {
prefix: 'Ftb',
length: 8,
includeUppercase: true,
includeLowercase: true,
includeNumbers: true,
includeSpecial: false,
};
export function generatePassword(rule: PasswordRule): string {
let chars = '';
if (rule.includeUppercase) chars += 'ABCDEFGHJKLMNPQRSTUVWXYZ';
if (rule.includeLowercase) chars += 'abcdefghjkmnpqrstuvwxyz';
if (rule.includeNumbers) chars += '23456789';
if (rule.includeSpecial) chars += '!@#$%&*';
if (!chars) chars = 'abcdefghjkmnpqrstuvwxyz23456789';
const randomLength = Math.max(rule.length - rule.prefix.length, 4);
let result = rule.prefix;
for (let i = 0; i < randomLength; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
export function maskPhone(phone: string): string {
if (!phone || phone.length < 7) return phone;
return phone.slice(0, 3) + '****' + phone.slice(7);
}

46
apps/web/lib/overtime.ts Normal file
View File

@@ -0,0 +1,46 @@
export interface OvertimeRecord {
id: string;
projectId: string;
versionId?: string;
person: string;
startTime: string;
endTime: string;
duration: number; // 小时,自动计算
reasonId: string;
remark?: string;
createdAt: string;
}
export type OvertimeReason =
| 'requirement_change'
| 'requirement_add'
| 'version_sprint'
| 'bug_fix'
| 'online_issue'
| 'test_fix'
| 'tech_difficulty'
| 'integration_issue'
| 'upstream_delay'
| 'overload'
| 'rework';
export const OVERTIME_REASON_LABEL: Record<string, string> = {
'reason-1': '需求变更',
'reason-2': '需求新增',
'reason-3': '版本冲刺',
'reason-4': 'Bug修复',
'reason-5': '线上问题修复',
'reason-6': '测试问题整改',
'reason-7': '技术难点攻关',
'reason-8': '联调问题',
'reason-9': '上游交付延误',
'reason-10': '工作量超负荷',
'reason-11': '返工修改',
};
export function calcDuration(start: string, end: string): number {
const s = new Date(start).getTime();
const e = new Date(end).getTime();
if (isNaN(s) || isNaN(e) || e <= s) return 0;
return Math.round(((e - s) / (1000 * 60 * 60)) * 10) / 10;
}

103
apps/web/lib/requirement.ts Normal file
View File

@@ -0,0 +1,103 @@
import type { Priority } from './derive';
export type RequirementStatus = 'pending_review' | 'adopted' | 'rejected' | 'planned' | 'developing' | 'testing' | 'released' | 'closed';
export type Effort = 'S' | 'M' | 'L' | 'XL';
export type SourceType = 'customer' | 'internal' | 'operation' | 'aftersale' | 'market' | 'competitor' | 'management';
export interface DictItem {
id: string;
name: string;
createdAt: string;
}
export interface SourceTarget {
id: string;
name: string;
sourceType: SourceType;
createdAt: string;
}
export interface Requirement {
id: string;
code: string;
title: string;
description: string;
productId: string;
projectId?: string;
versionId?: string;
sourceType: SourceType;
sourceTarget: string;
platforms: string[];
typeId: string;
status: RequirementStatus;
priority: Priority;
effort: Effort;
currentStage?: string;
productOwner?: string;
creator: string;
createdAt: string;
parentId?: string;
}
export const SOURCE_TYPE_LABEL: Record<SourceType, string> = {
customer: '客户',
internal: '内部',
operation: '运营',
aftersale: '售后',
market: '市场',
competitor: '竞品',
management: '管理层',
};
export const SOURCE_TARGET_LABEL: Record<SourceType, string> = {
customer: '来源客户',
internal: '提出部门',
operation: '提出部门',
aftersale: '提出部门',
market: '来源渠道',
competitor: '竞品名称',
management: '提出人',
};
export const REQ_STATUS_LABEL: Record<RequirementStatus, string> = {
pending_review: '待评审',
adopted: '已采纳',
rejected: '已拒绝',
planned: '已规划',
developing: '开发中',
testing: '测试中',
released: '已上线',
closed: '已关闭',
};
export const REQ_STATUS_COLOR: Record<RequirementStatus, string> = {
pending_review: 'bg-zinc-100 text-zinc-700 border border-zinc-200',
adopted: 'bg-emerald-50 text-emerald-700 border border-emerald-200',
rejected: 'bg-red-50 text-red-600 border border-red-200',
planned: 'bg-blue-50 text-blue-700 border border-blue-200',
developing: 'bg-indigo-50 text-indigo-700 border border-indigo-200',
testing: 'bg-purple-50 text-purple-700 border border-purple-200',
released: 'bg-emerald-50 text-emerald-700 border border-emerald-200',
closed: 'bg-zinc-50 text-zinc-500 border border-zinc-200',
};
export const EFFORT_LABEL: Record<Effort, string> = {
S: 'S (1-2天)',
M: 'M (3-5天)',
L: 'L (5-10天)',
XL: 'XL (10+天)',
};
export const EFFORT_SHORT: Record<Effort, string> = {
S: 'S',
M: 'M',
L: 'L',
XL: 'XL',
};
export const EFFORT_COLOR: Record<Effort, string> = {
S: 'bg-emerald-50 text-emerald-700',
M: 'bg-blue-50 text-blue-700',
L: 'bg-orange-50 text-orange-700',
XL: 'bg-red-50 text-red-700',
};

95
apps/web/lib/risk.ts Normal file
View File

@@ -0,0 +1,95 @@
import type { RoleProgress } from './derive';
export type RiskLevel = 'low' | 'medium' | 'high';
export type DelayStatus = 'normal' | 'warning' | 'delayed';
const DELAY_WARNING_DAYS = 3;
export function calcOverallProgress(progress?: RoleProgress[]): number {
if (!progress || progress.length === 0) return 0;
const sum = progress.reduce((s, p) => s + p.percent, 0);
return Math.round(sum / progress.length);
}
export function calcDelayStatus(
status: string,
expectedReleaseDate?: string | null,
): DelayStatus {
if (status === 'released' || status === 'closed') return 'normal';
if (!expectedReleaseDate) return 'normal';
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) return 'delayed';
if (diffDays <= DELAY_WARNING_DAYS) return 'warning';
return 'normal';
}
export function calcRiskLevel(
status: string,
startDate?: string | null,
expectedReleaseDate?: string | null,
progress?: RoleProgress[],
): RiskLevel {
if (status === 'released' || status === 'closed') return 'low';
if (status === 'paused') return 'medium';
const delayStatus = calcDelayStatus(status, expectedReleaseDate);
if (delayStatus === 'delayed') return 'high';
if (!startDate || !expectedReleaseDate) return 'low';
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) return 'low';
const elapsed = now.getTime() - start.getTime();
const timeProgress = Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));
const actualProgress = calcOverallProgress(progress);
const gap = timeProgress - actualProgress;
if (gap >= 40) return 'high';
if (gap >= 20 || delayStatus === 'warning') return 'medium';
return 'low';
}
export const RISK_LABEL: Record<RiskLevel, string> = {
low: '低',
medium: '中',
high: '高',
};
export const RISK_COLOR: Record<RiskLevel, string> = {
low: 'text-emerald-600',
medium: 'text-amber-600',
high: 'text-red-600',
};
export const RISK_DOT: Record<RiskLevel, string> = {
low: 'bg-emerald-500',
medium: 'bg-amber-500',
high: 'bg-red-500',
};
export const DELAY_LABEL: Record<DelayStatus, string> = {
normal: '正常',
warning: '即将延期',
delayed: '已延期',
};
export const DELAY_COLOR: Record<DelayStatus, string> = {
normal: 'bg-emerald-50 text-emerald-700',
warning: 'bg-amber-50 text-amber-700',
delayed: 'bg-red-50 text-red-700',
};

View File

@@ -1,19 +1,25 @@
export type VersionStatus = 'developing' | 'planned' | 'released';
export type VersionStatus = 'developing' | 'planned' | 'released' | 'paused' | 'closed';
export const VERSION_STATUS_LABEL: Record<VersionStatus, string> = {
developing: '开发中',
planned: '规划中',
released: '已发布',
paused: '已暂停',
closed: '已关闭',
};
export const VERSION_STATUS_DOT: Record<VersionStatus, string> = {
developing: 'bg-blue-500',
planned: 'bg-orange-500',
released: 'bg-zinc-300',
paused: 'bg-purple-400',
closed: 'bg-zinc-400',
};
export const VERSION_STATUS_BG: Record<VersionStatus, string> = {
developing: 'bg-blue-500/10 text-blue-600',
planned: 'bg-orange-500/10 text-orange-600',
released: 'bg-zinc-100 text-zinc-600',
paused: 'bg-purple-100 text-purple-600',
closed: 'bg-zinc-100 text-zinc-500',
};