fix: 概览排版优化 + 加班原因改为饼图

1. 参与人员/相关链接排版修复:
   - 去掉 grid-cols-3 不等高布局
   - 改为:总耗时独占一行 + 参与人员/相关链接并排双列
   - 不再有被撑高留空白的问题

2. 加班原因占比改为环形饼图:
   - SVG 环形饼图 + 右侧图例
   - 各原因用不同颜色区分
   - 图例显示名称 + 百分比

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-15 14:40:10 +08:00
parent 2c1b68c46f
commit b30c460571

View File

@@ -365,67 +365,67 @@ export default function VersionDetailPage() {
})()} })()}
{/* 4. 项目总耗时 + 参与人员 + 相关链接 */} {/* 4. 项目总耗时 + 参与人员 + 相关链接 */}
<div className="grid grid-cols-3 gap-4"> <div className="space-y-4">
<div className="col-span-2 space-y-4"> <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4"> {(() => {
{(() => { // 实际开始日期
// 实际开始日期 const vPlansAll = plans.filter((p) => p.versionId === version.id);
const vPlansAll = plans.filter((p) => p.versionId === version.id); const vReqsAll = requirements.filter((r) => r.versionId === version.id);
const vReqsAll = requirements.filter((r) => r.versionId === version.id); const vReqIdsAll = new Set(vReqsAll.map((r) => r.id));
const vReqIdsAll = new Set(vReqsAll.map((r) => r.id)); const vDTs = devTasks.filter((t) => vReqIdsAll.has(t.requirementId));
const vDTs = devTasks.filter((t) => vReqIdsAll.has(t.requirementId)); const vTCsAll = testCases.filter((c) => c.versionId === version.id);
const vTCsAll = testCases.filter((c) => c.versionId === version.id); const vBugsAll = bugs.filter((b) => b.versionId === version.id);
const vBugsAll = bugs.filter((b) => b.versionId === version.id);
const startDates: string[] = []; const startDates: string[] = [];
vPlansAll.forEach((p) => { if (p.actualStartAt) startDates.push(p.actualStartAt); }); vPlansAll.forEach((p) => { if (p.actualStartAt) startDates.push(p.actualStartAt); });
vDTs.forEach((t) => { if (t.startDate) startDates.push(t.startDate); }); vDTs.forEach((t) => { if (t.startDate) startDates.push(t.startDate); });
vTCsAll.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); }); vTCsAll.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
const actualStart = startDates.length > 0 ? startDates.sort()[0].slice(0, 10) : (version.startDate ?? null); const actualStart = startDates.length > 0 ? startDates.sort()[0].slice(0, 10) : (version.startDate ?? null);
// 实际截止日期 // 实际截止日期
const endDates: string[] = []; const endDates: string[] = [];
vPlansAll.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); }); vPlansAll.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
vDTs.forEach((t) => { if (t.completedAt) endDates.push(t.completedAt); }); vDTs.forEach((t) => { if (t.completedAt) endDates.push(t.completedAt); });
vTCsAll.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); }); vTCsAll.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
vBugsAll.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); }); vBugsAll.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); });
const actualEnd = endDates.length > 0 ? endDates.sort().reverse()[0].slice(0, 10) : null; const actualEnd = endDates.length > 0 ? endDates.sort().reverse()[0].slice(0, 10) : null;
// 逾期天数 // 逾期天数
const deadline = version.expectedReleaseDate; const deadline = version.expectedReleaseDate;
let overdueDays = 0; let overdueDays = 0;
if (deadline && actualEnd) { if (deadline && actualEnd) {
overdueDays = Math.floor((new Date(actualEnd).getTime() - new Date(deadline).getTime()) / (1000 * 60 * 60 * 24)); overdueDays = Math.floor((new Date(actualEnd).getTime() - new Date(deadline).getTime()) / (1000 * 60 * 60 * 24));
} }
return ( return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[13px]"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[13px]">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" /> <Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span> <span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{actualStart ?? '未开始'}</span> <span className="text-[var(--ink)]">{actualStart ?? '未开始'}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{deadline ?? '未设置'}</span>
</div>
{actualEnd && (
<div className="flex items-center gap-1.5">
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{actualEnd}</span>
</div>
)}
{overdueDays > 0 && (
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-red-50 text-red-600"> {overdueDays} </span>
)}
<div className="flex items-center gap-1.5 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="font-medium text-[var(--ink)]">{elapsedDays}</span>
</div>
</div> </div>
); <div className="flex items-center gap-1.5">
})()} <span className="text-[var(--ink-muted)]"></span>
</div> <span className="text-[var(--ink)]">{deadline ?? '未设置'}</span>
</div>
{actualEnd && (
<div className="flex items-center gap-1.5">
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{actualEnd}</span>
</div>
)}
{overdueDays > 0 && (
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-red-50 text-red-600"> {overdueDays} </span>
)}
<div className="flex items-center gap-1.5 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="font-medium text-[var(--ink)]">{elapsedDays}</span>
</div>
</div>
);
})()}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4"> <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="text-[11px] text-[var(--ink-muted)] font-medium"></div> <div className="text-[11px] text-[var(--ink-muted)] font-medium"></div>
@@ -449,9 +449,7 @@ export default function VersionDetailPage() {
); );
})()} })()}
</div> </div>
</div> <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="col-span-1">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 h-full">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div> <div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
{(() => { {(() => {
const vPlansCompleted = plans.filter((p) => p.versionId === version.id && p.status === 'completed' && p.resultUrl); const vPlansCompleted = plans.filter((p) => p.versionId === version.id && p.status === 'completed' && p.resultUrl);
@@ -507,28 +505,59 @@ export default function VersionDetailPage() {
)} )}
</div> </div>
{/* 加班原因占比 */} {/* 加班原因占比 - 饼图 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-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> <div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
{reasonRanking.length === 0 ? ( {reasonRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span> <span className="text-[12px] text-[var(--ink-muted)]"></span>
) : ( ) : (
<div className="space-y-2.5"> <div className="flex items-center gap-4">
{reasonRanking.map(([reasonId, hours]) => { {/* SVG 环形饼图 */}
const percent = Math.round((hours / reasonTotal) * 100); <svg viewBox="0 0 100 100" className="h-28 w-28 shrink-0">
const reasonName = OVERTIME_REASON_LABEL[reasonId] || reasonId; {(() => {
return ( const colors = ['#3b82f6', '#f97316', '#8b5cf6', '#10b981', '#ef4444', '#f59e0b', '#6366f1', '#ec4899'];
<div key={reasonId}> let cumPercent = 0;
<div className="flex items-center justify-between mb-1"> return reasonRanking.map(([reasonId, hours], i) => {
<span className="text-[12px] text-[var(--ink-soft)]">{reasonName}</span> const percent = hours / reasonTotal;
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{percent}%</span> 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="22" fill="var(--bg-card)" />
</svg>
{/* 图例 */}
<div className="space-y-1.5 flex-1">
{reasonRanking.map(([reasonId, hours], i) => {
const colors = ['#3b82f6', '#f97316', '#8b5cf6', '#10b981', '#ef4444', '#f59e0b', '#6366f1', '#ec4899'];
const percent = Math.round((hours / reasonTotal) * 100);
const reasonName = OVERTIME_REASON_LABEL[reasonId] || reasonId;
return (
<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">{reasonName}</span>
<span className="tabular-nums text-[var(--ink-muted)]">{percent}%</span>
</div> </div>
<div className="h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden"> );
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${percent}%` }} /> })}
</div> </div>
</div>
);
})}
</div> </div>
)} )}
</div> </div>