fix: 去掉范围蔓延和其他选项 + 概览增加变更统计

1. 变更原因精简为4个:
   - 需求理解偏差导致返工
   - 反馈导致返工
   - 实现难度超预期
   - 上游交付延误
   去掉"范围蔓延"和"其他"(避免大家逃避问题选其他)

2. 顶部小卡片新增"需求变更"计数(橙色警告色)

3. 概览新增变更统计区域(加班时长下方):
   - 变更人员排名:谁发起的变更次数最多
   - 变更原因占比:环形饼图 + 图例百分比
   - 无变更时不显示此区域

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-15 16:28:37 +08:00
parent 700c8a9aba
commit 9c310534e6
2 changed files with 81 additions and 5 deletions

View File

@@ -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 (
<div className="grid grid-cols-4 gap-3 sm:grid-cols-7">
<div className="grid grid-cols-4 gap-3 sm:grid-cols-8">
<StatCard label="关联需求" value={versionReqs.length} />
<StatCard label="需求变更" value={versionReqs.filter((r) => r.reqType === 'change').length} warn={versionReqs.filter((r) => r.reqType === 'change').length > 0} />
<StatCard label="待调研" value={pendingResearch} warn={pendingResearch > 0} />
<StatCard label="待方案设计" value={pendingProduct} warn={pendingProduct > 0} />
<StatCard label="待开发" value={devTaskTodo} />
@@ -586,6 +587,83 @@ export default function VersionDetailPage() {
</div>
</div>
{/* 6. 需求变更统计 */}
{(() => {
const changeReqs = requirements.filter((r) => r.versionId === version.id && r.reqType === 'change');
if (changeReqs.length === 0) return null;
// 变更人员排名
const personChange: Record<string, number> = {};
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<string, number> = {};
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 (
<div className="grid grid-cols-2 gap-4">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
{changePersonRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="space-y-2">
{changePersonRanking.map(([name, count], i) => (
<div key={name} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-[var(--ink-muted)] w-4">{i + 1}</span>
<span className="text-[12px] text-[var(--ink)]">{name}</span>
</div>
<span className="text-[12px] font-medium tabular-nums text-orange-600">{count} </span>
</div>
))}
</div>
)}
</div>
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
<div className="flex items-center gap-4">
<svg viewBox="0 0 100 100" className="h-24 w-24 shrink-0">
{(() => {
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 <circle key={reasonId} cx="50" cy="50" r="40" fill={colors[i % colors.length]} />;
return <path key={reasonId} d={`M 50 50 L ${x1} ${y1} A 40 40 0 ${largeArc} 1 ${x2} ${y2} Z`} fill={colors[i % colors.length]} />;
});
})()}
<circle cx="50" cy="50" r="20" fill="var(--bg-card)" />
</svg>
<div className="space-y-1.5 flex-1">
{changeReasonRanking.map(([reasonId, count], i) => (
<div key={reasonId} className="flex items-center gap-2 text-[11px]">
<span className="h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: colors[i % colors.length] }} />
<span className="flex-1 text-[var(--ink-soft)] truncate">{CHANGE_REASON_LABEL[reasonId as keyof typeof CHANGE_REASON_LABEL] || reasonId}</span>
<span className="tabular-nums text-[var(--ink-muted)]">{Math.round((count / reasonTotal) * 100)}%</span>
</div>
))}
</div>
</div>
</div>
</div>
);
})()}
{/* 阶段耗时 + 个人耗时排名 */}
{(() => {
const versionPlans = plans.filter((p) => p.versionId === version.id);

View File

@@ -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<ChangeReason, string> = {
misunderstanding: '需求理解偏差导致返工',
feedback_rework: '反馈导致返工',
difficulty_exceeded: '实现难度超预期',
upstream_delay: '上游交付延误',
scope_creep: '范围蔓延',
other: '其他',
};
export const SOURCE_TYPE_LABEL: Record<SourceType, string> = {