diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index 02b38df..3f5191f 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -13,7 +13,7 @@ import { CapsuleStages } from '@/components/version/CapsuleStages'; import { MemberChips } from '@/components/version/MemberChips'; import { HealthTrend, generateMockTrend } from '@/components/version/HealthTrend'; import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health'; -import { REQ_STATUS_LABEL, REQ_STATUS_COLOR } from '@/lib/requirement'; +import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, CHANGE_REASON_LABEL } from '@/lib/requirement'; import { OVERTIME_REASON_LABEL } from '@/lib/overtime'; import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab'; import { PlanTab } from '@/components/version/PlanTab'; @@ -285,8 +285,9 @@ export default function VersionDetailPage() { const pendingResearch = vPlans.filter((p) => p.type === 'research' && p.status !== 'completed').length; const pendingProduct = vPlans.filter((p) => p.type === 'product' && p.status !== 'completed').length; return ( -
+
+ r.reqType === 'change').length} warn={versionReqs.filter((r) => r.reqType === 'change').length > 0} /> 0} /> 0} /> @@ -586,6 +587,83 @@ export default function VersionDetailPage() {
+ {/* 6. 需求变更统计 */} + {(() => { + const changeReqs = requirements.filter((r) => r.versionId === version.id && r.reqType === 'change'); + if (changeReqs.length === 0) return null; + + // 变更人员排名 + const personChange: Record = {}; + changeReqs.forEach((r) => { const by = r.changeBy || r.creator; personChange[by] = (personChange[by] || 0) + 1; }); + const changePersonRanking = Object.entries(personChange).sort((a, b) => b[1] - a[1]); + + // 变更原因占比 + const reasonCount: Record = {}; + changeReqs.forEach((r) => { if (r.changeReason) reasonCount[r.changeReason] = (reasonCount[r.changeReason] || 0) + 1; }); + const changeReasonRanking = Object.entries(reasonCount).sort((a, b) => b[1] - a[1]); + const reasonTotal = changeReqs.length; + + const colors = ['#f97316', '#3b82f6', '#8b5cf6', '#10b981']; + + return ( +
+
+
变更人员排名
+ {changePersonRanking.length === 0 ? ( + 暂无数据 + ) : ( +
+ {changePersonRanking.map(([name, count], i) => ( +
+
+ {i + 1} + {name} +
+ {count} 次 +
+ ))} +
+ )} +
+
+
变更原因占比
+
+ + {(() => { + let cumPercent = 0; + return changeReasonRanking.map(([reasonId, count], i) => { + const percent = count / reasonTotal; + const startAngle = cumPercent * 360; + cumPercent += percent; + const endAngle = cumPercent * 360; + const largeArc = percent > 0.5 ? 1 : 0; + const startRad = ((startAngle - 90) * Math.PI) / 180; + const endRad = ((endAngle - 90) * Math.PI) / 180; + const x1 = 50 + 40 * Math.cos(startRad); + const y1 = 50 + 40 * Math.sin(startRad); + const x2 = 50 + 40 * Math.cos(endRad); + const y2 = 50 + 40 * Math.sin(endRad); + if (percent >= 1) return ; + return ; + }); + })()} + + +
+ {changeReasonRanking.map(([reasonId, count], i) => ( +
+ + {CHANGE_REASON_LABEL[reasonId as keyof typeof CHANGE_REASON_LABEL] || reasonId} + {Math.round((count / reasonTotal) * 100)}% +
+ ))} +
+
+
+
+ ); + })()} + {/* 阶段耗时 + 个人耗时排名 */} {(() => { const versionPlans = plans.filter((p) => p.versionId === version.id); diff --git a/apps/web/lib/requirement.ts b/apps/web/lib/requirement.ts index 98b4a36..7afa210 100644 --- a/apps/web/lib/requirement.ts +++ b/apps/web/lib/requirement.ts @@ -43,15 +43,13 @@ export interface Requirement { changeBy?: string; } -export type ChangeReason = 'misunderstanding' | 'feedback_rework' | 'difficulty_exceeded' | 'upstream_delay' | 'scope_creep' | 'other'; +export type ChangeReason = 'misunderstanding' | 'feedback_rework' | 'difficulty_exceeded' | 'upstream_delay'; export const CHANGE_REASON_LABEL: Record = { misunderstanding: '需求理解偏差导致返工', feedback_rework: '反馈导致返工', difficulty_exceeded: '实现难度超预期', upstream_delay: '上游交付延误', - scope_creep: '范围蔓延', - other: '其他', }; export const SOURCE_TYPE_LABEL: Record = {