fix(version): 修正概览耗时与排名统计

This commit is contained in:
Script Generator
2026-06-26 13:06:22 +08:00
parent 4cda624dc7
commit dde7a9a623
9 changed files with 857 additions and 367 deletions

View File

@@ -2,18 +2,15 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ChevronLeft, Package, Calendar, Clock, ExternalLink, FileText, Palette, Layout, Link2, Settings, Plus, X } from 'lucide-react';
import { Calendar, ChevronLeft, Clock, FileText, Link2, Plus, Settings, X } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { getVersionDetail } from '@/lib/derive';
import { STAGES, Role } from '@/lib/stage';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG, getVersionDisplayStatus, calcVersionExecutionStatus, EXECUTION_STATUS_LABEL, EXECUTION_STATUS_COLOR } from '@/lib/version-status';
import type { Role } from '@/lib/stage';
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, CHANGE_REASON_LABEL } from '@/lib/requirement';
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health';
import { 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';
@@ -26,22 +23,24 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { calcGroupProgress as calcDevTaskProgress, getActualHours, getEstimateHours, aggregateDevTaskHours, devTaskIntervals } from '@/lib/dev-task';
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
import { hasPermission } from '@/lib/permissions';
import { calcWorkHours, formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
import { testCaseIntervals } from '@/lib/test-case';
import { bugIntervals } from '@/lib/bug';
import { planIntervals } from '@/lib/version-plan';
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
import { formatDateTime } from '@/lib/format';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
const PRIORITY_STYLE: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600',
P1: 'bg-orange-500/10 text-orange-600',
P2: 'bg-blue-500/10 text-blue-600',
P3: 'bg-zinc-100 text-zinc-600',
P4: 'bg-zinc-100 text-zinc-500',
};
function formatOverviewDateTime(value?: string | null): string {
if (!value) return '-';
return value.includes('T') ? formatDateTime(value) : value;
}
const BUG_SEVERITY_SEGMENTS = [
{ key: 'critical', label: '致命', color: 'bg-red-500' },
{ key: 'major', label: '严重', color: 'bg-orange-500' },
{ key: 'minor', label: '一般', color: 'bg-amber-400' },
{ key: 'trivial', label: '轻微', color: 'bg-zinc-400' },
] as const;
const TABS = [
{ key: 'overview', label: '概览', permission: null as string | null },
@@ -111,32 +110,6 @@ export default function VersionDetailPage() {
}
}, [plans, version, versionId, updateVersion]);
const elapsedDays = useMemo(() => {
if (!version) return 0;
// 从所有阶段中取最早的实际开始时间
const vPlans = plans.filter((p) => p.versionId === version.id);
const vReqs = requirements.filter((r) => r.versionId === version.id);
const vReqIds = new Set(vReqs.map((r) => r.id));
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
const vTCs = testCases.filter((c) => c.versionId === version.id);
const startDates: string[] = [];
vPlans.forEach((p) => {
if (p.actualStartAt) startDates.push(p.actualStartAt);
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime);
});
vDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
if (startDates.length === 0 && !version.startDate) return 0;
const earliest = startDates.length > 0 ? startDates.sort()[0] : version.startDate!;
const start = new Date(earliest);
start.setHours(0, 0, 0, 0);
const now = new Date();
now.setHours(0, 0, 0, 0);
return Math.max(0, Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)));
}, [version, plans, requirements, devTasks, testCases]);
if (!version) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
@@ -240,9 +213,38 @@ export default function VersionDetailPage() {
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
{activeTab === 'overview' ? (
(() => {
const now = new Date();
const versionReqs = requirements.filter((r) => r.versionId === version.id);
const versionReqIds = new Set(versionReqs.map((r) => r.id));
const versionPlans = plans.filter((p) => p.versionId === version.id);
const versionDevTasks = devTasks.filter((t) => versionReqIds.has(t.requirementId));
const versionTCs = testCases.filter((c) => c.versionId === version.id);
const versionBugs = bugs.filter((b) => b.versionId === version.id);
const versionOT = records.filter((r) => r.versionId === version.id);
const totalOTHours = Math.round(versionOT.reduce((sum, r) => sum + r.duration, 0) * 10) / 10;
const stageEffortMetrics = calcStageEffortMetrics({
plans: versionPlans,
devTasks: versionDevTasks,
testCases: versionTCs,
bugs: versionBugs,
now,
});
const effortTotals = calcVersionOverviewEffortTotals({
plans: versionPlans,
devTasks: versionDevTasks,
testCases: versionTCs,
bugs: versionBugs,
overtimeRecords: versionOT,
now,
});
const personalRanking = calcPersonalEffortRanking({
plans: versionPlans,
devTasks: versionDevTasks,
testCases: versionTCs,
bugs: versionBugs,
overtimeRecords: versionOT,
now,
});
const bugRanking = calcBugSeverityRanking(versionBugs);
// 人员加班排名
const personOT: Record<string, number> = {};
@@ -255,23 +257,6 @@ export default function VersionDetailPage() {
const reasonRanking = Object.entries(reasonMap).sort((a, b) => b[1] - a[1]);
const reasonTotal = reasonRanking.reduce((s, [, v]) => s + v, 0) || 1;
// DevTask 真实统计
const versionDevTasks = devTasks.filter((t) => versionReqs.some((r) => r.id === t.requirementId));
const devTaskTodo = versionDevTasks.filter((t) => t.status === 'todo').length;
const devTaskInProgress = versionDevTasks.filter((t) => t.status === 'in_progress' || t.status === 'testing').length;
const devTaskBlocked = versionDevTasks.filter((t) => t.isBlocked).length;
const versionBugs = bugs.filter((b) => b.versionId === version.id);
const bugOpenCount = versionBugs.filter((b) => b.status === 'open' || b.status === 'fixing').length;
// 版本执行态推导
const versionTCs = testCases.filter((c) => c.versionId === version.id);
const executionStatus = calcVersionExecutionStatus({
manualStatus: version.status,
devTasks: versionDevTasks,
testCases: versionTCs,
bugs: versionBugs,
});
return (
<div className="space-y-4">
{/* 1. 风险详情 - 最上方 */}
@@ -297,33 +282,13 @@ export default function VersionDetailPage() {
</div>
)}
{/* 2. 统计卡片 */}
{/* 2. 阶段概览 */}
{(() => {
const vPlans = plans.filter((p) => p.versionId === version.id);
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-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} />
<StatCard label="开发中" value={devTaskInProgress} accent />
<StatCard label="未关闭Bug" value={bugOpenCount} warn={bugOpenCount > 0} />
<StatCard label="加班时长" value={`${totalOTHours}h`} accent />
</div>
);
})()}
const researchPlans = versionPlans.filter((p) => p.type === 'research');
const productPlans = versionPlans.filter((p) => p.type === 'product');
const uiPlans = versionPlans.filter((p) => p.type === 'ui');
{/* 3. 状态胶囊 */}
{(() => {
const vPlans = plans.filter((p) => p.versionId === version.id);
const researchPlans = vPlans.filter((p) => p.type === 'research');
const productPlans = vPlans.filter((p) => p.type === 'product');
const uiPlans = vPlans.filter((p) => p.type === 'ui');
const calcGroupProgress = (group: typeof vPlans, type: 'research' | 'product' | 'ui') => {
const calcGroupProgress = (group: typeof versionPlans, type: 'research' | 'product' | 'ui') => {
if (group.length === 0) return 0;
// 分子=已完成子任务数,分母=所有plan的子任务总数
// completed 的 plan其子任务全算已完成
@@ -354,120 +319,132 @@ export default function VersionDetailPage() {
return totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0;
};
const getPlanStatus = (group: typeof vPlans): 'idle' | 'active' | 'done' => {
const getPlanStatus = (group: typeof versionPlans): 'idle' | 'active' | 'done' => {
if (group.length === 0) return 'idle';
if (group.every((p) => p.status === 'completed')) return 'done';
if (group.some((p) => p.status === 'in_progress')) return 'active';
return 'idle';
};
type StageProgressItem = { percent: number; status: 'idle' | 'active' | 'done' };
type StageProgressItem = { percent: number; status: 'idle' | 'active' | 'done'; actualHours?: number; aiEstimateHours?: number };
const stageProgress: Partial<Record<string, StageProgressItem>> = {};
if (researchPlans.length > 0) stageProgress['requirement'] = { percent: calcGroupProgress(researchPlans, 'research'), status: getPlanStatus(researchPlans) };
if (productPlans.length > 0) stageProgress['product_design'] = { percent: calcGroupProgress(productPlans, 'product'), status: getPlanStatus(productPlans) };
if (uiPlans.length > 0) stageProgress['ui_design'] = { percent: calcGroupProgress(uiPlans, 'ui'), status: getPlanStatus(uiPlans) };
stageProgress['requirement'] = {
percent: calcGroupProgress(researchPlans, 'research'),
status: getPlanStatus(researchPlans),
...stageEffortMetrics.requirement,
};
stageProgress['product_design'] = {
percent: calcGroupProgress(productPlans, 'product'),
status: getPlanStatus(productPlans),
...stageEffortMetrics.product_design,
};
stageProgress['ui_design'] = {
percent: calcGroupProgress(uiPlans, 'ui'),
status: getPlanStatus(uiPlans),
...stageEffortMetrics.ui_design,
};
// 开发阶段
if (versionDevTasks.length > 0) {
const devProgress = calcDevTaskProgress(versionDevTasks);
const allSubmitted = versionDevTasks.every((t) => t.status === 'submitted');
const hasActive = versionDevTasks.some((t) => t.status === 'in_progress' || t.status === 'testing');
stageProgress['dev'] = { percent: devProgress, status: allSubmitted ? 'done' : hasActive ? 'active' : 'idle' };
}
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
const hasActiveDevTask = versionDevTasks.some((t) => t.status === 'in_progress' || t.status === 'testing');
stageProgress['dev'] = {
percent: versionDevTasks.length > 0 ? calcDevTaskProgress(versionDevTasks) : 0,
status: versionDevTasks.length === 0 ? 'idle' : allSubmitted ? 'done' : hasActiveDevTask ? 'active' : 'idle',
...stageEffortMetrics.dev,
};
// 测试阶段
if (versionTCs.length > 0) {
const executed = versionTCs.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
const testPercent = Math.round((executed / versionTCs.length) * 100);
const allPassed = versionTCs.every((c) => c.status === 'passed');
const hasRunning = versionTCs.some((c) => c.status === 'running');
stageProgress['testing'] = { percent: testPercent, status: allPassed ? 'done' : (hasRunning || executed > 0) ? 'active' : 'idle' };
}
const executed = versionTCs.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
const testPercent = versionTCs.length > 0 ? Math.round((executed / versionTCs.length) * 100) : 0;
const allPassed = versionTCs.length > 0 && versionTCs.every((c) => c.status === 'passed');
const hasRunning = versionTCs.some((c) => c.status === 'running');
stageProgress['testing'] = {
percent: testPercent,
status: versionTCs.length === 0 ? 'idle' : allPassed ? 'done' : (hasRunning || executed > 0) ? 'active' : 'idle',
...stageEffortMetrics.testing,
};
// BUG 阶段已关闭Bug / 总Bug
if (versionBugs.length > 0) {
const closedBugs = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length;
const bugPercent = Math.round((closedBugs / versionBugs.length) * 100);
const allClosed = versionBugs.every((b) => b.status === 'closed' || b.status === 'rejected');
stageProgress['bug'] = { percent: bugPercent, status: allClosed ? 'done' : closedBugs > 0 || versionBugs.length > 0 ? 'active' : 'idle' };
}
const closedBugs = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length;
const bugPercent = versionBugs.length > 0 ? Math.round((closedBugs / versionBugs.length) * 100) : 0;
const allClosed = versionBugs.length > 0 && versionBugs.every((b) => b.status === 'closed' || b.status === 'rejected');
stageProgress['bug'] = {
percent: bugPercent,
status: versionBugs.length === 0 ? 'idle' : allClosed ? 'done' : 'active',
...stageEffortMetrics.bug,
};
return <CapsuleStages stageProgress={stageProgress as any} />;
})()}
{/* 4. 项目总耗时 + 参与人员 + 相关链接 */}
{/* 3. 时间与投入概览 + 参与人员 + 相关链接 */}
<div className="space-y-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 vReqsAll = requirements.filter((r) => r.versionId === version.id);
const vReqIdsAll = new Set(vReqsAll.map((r) => r.id));
const vDTs = devTasks.filter((t) => vReqIdsAll.has(t.requirementId));
const vTCsAll = testCases.filter((c) => c.versionId === version.id);
const vBugsAll = bugs.filter((b) => b.versionId === version.id);
{(() => {
const startDates: string[] = [];
vPlansAll.forEach((p) => {
versionPlans.forEach((p) => {
if (p.actualStartAt) startDates.push(p.actualStartAt);
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime);
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= now) startDates.push(p.startTime);
});
vDTs.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
vTCsAll.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
const actualStart = startDates.length > 0 ? formatDateTime(startDates.sort()[0]) : (version.startDate ?? null);
versionDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
versionTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
const actualStartIso = startDates.length > 0 ? startDates.sort()[0] : (version.startDate ?? null);
// 实际截止日期
const endDates: string[] = [];
vPlansAll.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
vDTs.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); });
vTCsAll.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
vBugsAll.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); });
const actualEnd = endDates.length > 0 ? formatDateTime(endDates.sort().reverse()[0]) : null;
versionPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
versionDevTasks.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); });
versionTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
versionBugs.forEach((b) => {
if (b.closedAt) endDates.push(b.closedAt);
else if (b.resolvedAt) endDates.push(b.resolvedAt);
else if ((b.status === 'closed' || b.status === 'rejected') && b.updatedAt) endDates.push(b.updatedAt);
});
const actualEndIso = endDates.length > 0 ? endDates.sort().reverse()[0] : null;
// 逾期天数
const isTerminalVersion = version.status === 'released' || version.status === 'closed';
const deadline = version.expectedReleaseDate;
const versionActualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
let overdueDays = 0;
if (deadline && actualEnd) {
overdueDays = Math.floor((new Date(actualEnd.replace(' ', 'T')).getTime() - new Date(deadline).getTime()) / (1000 * 60 * 60 * 24));
if (deadline && isTerminalVersion && actualEndIso) {
const endDate = new Date(actualEndIso);
const deadlineDate = new Date(deadline);
endDate.setHours(0, 0, 0, 0);
deadlineDate.setHours(0, 0, 0, 0);
overdueDays = Math.floor((endDate.getTime() - deadlineDate.getTime()) / (1000 * 60 * 60 * 24));
}
// 总耗时 = 日历天数 + 加班时长
const versionOTForTime = records.filter((r) => r.versionId === version.id);
const otHoursTotal = Math.round(versionOTForTime.reduce((sum, r) => sum + r.duration, 0) * 10) / 10;
const metrics = [
{ label: '开始', value: actualStartIso ? formatOverviewDateTime(actualStartIso) : '未开始', icon: <Calendar className="h-3.5 w-3.5" /> },
{ label: '预计截止', value: deadline ?? '未设置' },
{ label: '实际截止', value: isTerminalVersion ? (actualEndIso ? formatOverviewDateTime(actualEndIso) : '未记录') : '未完成' },
{ label: '实际耗时', value: formatActualDuration(versionActualHours), icon: <Clock className="h-3.5 w-3.5" /> },
{ label: '人力总投入', value: formatActualDuration(effortTotals.actualHours), tone: 'accent' as const },
{ label: '加班时长', value: formatActualDuration(effortTotals.overtimeHours), sub: '来自加班记录', tone: 'orange' as const },
];
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[13px]">
<div className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{actualStart ?? '未开始'}</span>
<div className="overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--bg-card)]">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
{metrics.map((item) => (
<OverviewMetric
key={item.label}
label={item.label}
value={item.value}
icon={item.icon}
sub={item.sub}
tone={item.tone}
/>
))}
</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>
{overdueDays > 0 && (
<div className="border-t border-red-100 bg-red-50 px-4 py-2 text-[12px] font-medium text-red-600">
{overdueDays}
</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>
{otHoursTotal > 0 && (
<span className="text-[var(--ink-muted)]"> <span className="font-medium text-orange-600">{otHoursTotal}h</span></span>
)}
</div>
</div>
);
})()}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<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="text-[11px] text-[var(--ink-muted)] font-medium"></div>
@@ -494,7 +471,7 @@ export default function VersionDetailPage() {
<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>
{(() => {
const vPlansCompleted = plans.filter((p) => p.versionId === version.id && p.status === 'completed' && p.resultUrl);
const vPlansCompleted = versionPlans.filter((p) => p.status === 'completed' && p.resultUrl);
const typeLabel: Record<string, string> = { research: '调研', product: '产品方案', ui: 'UI设计' };
const grouped = ['research', 'product', 'ui'].map((type) => ({
type,
@@ -682,170 +659,116 @@ export default function VersionDetailPage() {
);
})()}
{/* 阶段耗时 + 个人耗时排名 */}
{(() => {
const versionPlans = plans.filter((p) => p.versionId === version.id);
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{/* 个人耗时排名 */}
{(() => {
const maxHours = personalRanking[0]?.total || 1;
// 各阶段日历天数计算辅助函数
const calcCalendar = (starts: string[], ends: string[]) => {
const sortedStarts = starts.filter(Boolean).sort();
const sortedEnds = ends.filter(Boolean).sort().reverse();
const start = sortedStarts[0] || null;
const end = sortedEnds[0] || null;
const days = start && end ? Math.max(1, Math.ceil((new Date(end).getTime() - new Date(start).getTime()) / (1000 * 60 * 60 * 24)) + 1) : 0;
return { start, end, days };
};
// 调研阶段
const researchPlans = versionPlans.filter((p) => p.type === 'research');
const researchCal = calcCalendar(
researchPlans.filter((p) => p.actualStartAt).map((p) => p.actualStartAt!),
researchPlans.filter((p) => p.completedAt).map((p) => p.completedAt!),
);
// 产品方案阶段
const productPlans = versionPlans.filter((p) => p.type === 'product');
const productCal = calcCalendar(
productPlans.filter((p) => p.actualStartAt).map((p) => p.actualStartAt!),
productPlans.filter((p) => p.completedAt).map((p) => p.completedAt!),
);
// UI设计阶段
const uiPlans = versionPlans.filter((p) => p.type === 'ui');
const uiCal = calcCalendar(
uiPlans.filter((p) => p.actualStartAt).map((p) => p.actualStartAt!),
uiPlans.filter((p) => p.completedAt).map((p) => p.completedAt!),
);
// 开发阶段
const devCal = calcCalendar(
versionDevTasks.filter((t) => t.actualStartAt).map((t) => t.actualStartAt!),
versionDevTasks.filter((t) => t.actualEndAt).map((t) => t.actualEndAt!),
);
// 测试阶段
const tcCal = calcCalendar(
versionTCs.filter((c) => c.startedAt).map((c) => c.startedAt!),
versionTCs.filter((c) => c.completedAt).map((c) => c.completedAt!),
);
const stages = [
{ label: '调研', ...researchCal, color: 'bg-orange-400' },
{ label: '产品方案', ...productCal, color: 'bg-pink-400' },
{ label: 'UI设计', ...uiCal, color: 'bg-indigo-400' },
{ label: '开发', ...devCal, color: 'bg-blue-400' },
{ label: '测试', ...tcCal, color: 'bg-purple-400' },
];
const maxDays = Math.max(...stages.map((s) => s.days), 1);
// 个人维度:每人耗时汇总
const personalHours = new Map<string, { research: number; product: number; ui: number; dev: number; test: number }>();
const addHours = (name: string, key: 'research' | 'product' | 'ui' | 'dev' | 'test', hours: number) => {
const prev = personalHours.get(name) || { research: 0, product: 0, ui: 0, dev: 0, test: 0 };
prev[key] += hours;
personalHours.set(name, prev);
};
// 调研/产品/UI 用 startTime→endTime 计算
versionPlans.forEach((p) => {
if (!p.owner || !p.actualStartAt) return;
const hours = calcWorkHours(p.actualStartAt, p.completedAt ?? new Date().toISOString());
addHours(p.owner, p.type === 'research' ? 'research' : p.type === 'product' ? 'product' : 'ui', hours);
});
versionDevTasks.forEach((t) => {
if (!t.actualStartAt || !t.assigneeId) return;
const endIso = t.actualEndAt ?? new Date().toISOString();
addHours(t.assigneeId, 'dev', calcWorkHours(t.actualStartAt, endIso));
});
versionTCs.forEach((c) => {
if (!c.startedAt || !c.assigneeId) return;
addHours(c.assigneeId, 'test', calcWorkHours(c.startedAt, c.completedAt ?? new Date().toISOString()));
});
const personalRanking = Array.from(personalHours.entries())
.map(([name, h]) => ({ name, ...h, total: h.research + h.product + h.ui + h.dev + h.test }))
.sort((a, b) => b.total - a.total);
const maxHours = personalRanking[0]?.total || 1;
const totalHours = personalRanking.reduce((s, p) => s + p.total, 0);
// 双口径:日历总耗时(合并区间)+ 人力总投入(即 totalHours
const allIntervals = [
...planIntervals(versionPlans),
...devTaskIntervals(versionDevTasks),
...testCaseIntervals(versionTCs),
...bugIntervals(versionBugs),
];
const { calendarHours: versionCalendarHours } = calcTwoMetrics(allIntervals);
return (
<div className="grid grid-cols-2 gap-4">
{/* 阶段日历耗时 */}
return (
<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="space-y-2.5">
{stages.map((s) => (
<div key={s.label}>
<div className="flex items-center justify-between mb-1">
<span className="text-[12px] text-[var(--ink-soft)]">{s.label}</span>
<span className="text-[12px] font-medium tabular-nums text-[var(--ink)]">{s.days > 0 ? `${s.days}` : '-'}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
<div className={`h-full rounded-full ${s.color}`} style={{ width: `${(s.days / maxDays) * 100}%` }} />
</div>
{s.start && <span className="text-[9px] text-[var(--ink-muted)] shrink-0 tabular-nums">{s.start.slice(5)}{s.end ? `${s.end.slice(5)}` : ' →'}</span>}
</div>
</div>
))}
<div className="pt-2 border-t border-[var(--line)] space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[12px] text-[var(--ink-soft)]"></span>
<span className="text-[12px] font-medium tabular-nums text-[var(--ink)]">{formatWorkHours(versionCalendarHours)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-[12px] text-[var(--ink-soft)]"></span>
<span className="text-[12px] font-medium tabular-nums text-[var(--ink)]">{formatWorkHours(totalHours)}</span>
</div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
<div className="flex items-center gap-3 text-[10px] text-[var(--ink-muted)]">
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-[var(--accent)]" /></span>
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-orange-500" /></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>
{personalRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="space-y-2">
{personalRanking.slice(0, 8).map((item, i) => (
<div key={item.name}>
<div className="flex items-center gap-2 mb-1">
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold ${i < 3 ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'bg-[var(--bg-subtle)] text-[var(--ink-muted)]'}`}>{i + 1}</span>
<span className="flex-1 text-[12px] text-[var(--ink)]">{item.name}</span>
<span className="text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{item.total}h</span>
<div className="space-y-2.5">
{personalRanking.slice(0, 8).map((item, i) => {
const totalWidth = (item.total / maxHours) * 100;
const actualWidth = item.total > 0 ? (item.actualHours / item.total) * 100 : 0;
const overtimeWidth = item.total > 0 ? (item.overtimeHours / item.total) * 100 : 0;
return (
<div key={item.name}>
<div className="mb-1 flex items-center gap-2">
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold ${i < 3 ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'bg-[var(--bg-subtle)] text-[var(--ink-muted)]'}`}>{i + 1}</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-[var(--ink)]">{item.name}</span>
<span className="shrink-0 text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{formatActualDuration(item.total)}</span>
</div>
<div className="ml-7 h-2 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
<div className="flex h-full overflow-hidden rounded-full" style={{ width: `${totalWidth}%` }}>
{item.actualHours > 0 && <div className="h-full bg-[var(--accent)]" style={{ width: `${actualWidth}%` }} />}
{item.overtimeHours > 0 && <div className="h-full bg-orange-500" style={{ width: `${overtimeWidth}%` }} />}
</div>
</div>
<div className="ml-7 mt-1 flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] text-[var(--ink-muted)]">
<span> {formatActualDuration(item.actualHours)}</span>
<span> {formatActualDuration(item.overtimeHours)}</span>
</div>
</div>
<div className="ml-7 flex items-center gap-0.5 h-1.5">
{item.research > 0 && <div className="h-full rounded-full bg-orange-400" style={{ width: `${(item.research / maxHours) * 100}%` }} title={`调研 ${formatWorkHours(item.research)}`} />}
{item.product > 0 && <div className="h-full rounded-full bg-pink-400" style={{ width: `${(item.product / maxHours) * 100}%` }} title={`产品 ${formatWorkHours(item.product)}`} />}
{item.ui > 0 && <div className="h-full rounded-full bg-indigo-400" style={{ width: `${(item.ui / maxHours) * 100}%` }} title={`UI ${formatWorkHours(item.ui)}`} />}
{item.dev > 0 && <div className="h-full rounded-full bg-blue-400" style={{ width: `${(item.dev / maxHours) * 100}%` }} title={`开发 ${formatWorkHours(item.dev)}`} />}
{item.test > 0 && <div className="h-full rounded-full bg-purple-400" style={{ width: `${(item.test / maxHours) * 100}%` }} title={`测试 ${formatWorkHours(item.test)}`} />}
</div>
</div>
))}
<div className="flex items-center gap-3 pt-2 text-[10px] text-[var(--ink-muted)] flex-wrap">
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-orange-400" /></span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-pink-400" /></span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-indigo-400" />UI</span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-blue-400" /></span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-purple-400" /></span>
</div>
);
})}
</div>
)}
</div>
</div>
);
})()}
);
})()}
{/* Bug 数排名 */}
{(() => {
const maxBugCount = bugRanking[0]?.total || 1;
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] font-medium text-[var(--ink-muted)]">Bug </div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-[var(--ink-muted)]">
{BUG_SEVERITY_SEGMENTS.map((segment) => (
<span key={segment.key} className="inline-flex items-center gap-1">
<span className={`h-2 w-2 rounded-sm ${segment.color}`} />
{segment.label}
</span>
))}
</div>
</div>
{bugRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="space-y-2.5">
{bugRanking.slice(0, 8).map((item, i) => {
const totalWidth = (item.total / maxBugCount) * 100;
return (
<div key={item.assigneeId}>
<div className="mb-1 flex items-center gap-2">
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold ${i < 3 ? 'bg-red-50 text-red-600' : 'bg-[var(--bg-subtle)] text-[var(--ink-muted)]'}`}>{i + 1}</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-[var(--ink)]">{item.assigneeId}</span>
<span className="shrink-0 text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{item.total} </span>
</div>
<div className="ml-7 h-2 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
<div className="flex h-full overflow-hidden rounded-full" style={{ width: `${totalWidth}%` }}>
{BUG_SEVERITY_SEGMENTS.map((segment) => {
const count = item[segment.key];
if (count <= 0) return null;
return (
<div
key={segment.key}
className={`h-full ${segment.color}`}
style={{ width: `${(count / item.total) * 100}%` }}
title={`${segment.label} ${count}`}
/>
);
})}
</div>
</div>
<div className="ml-7 mt-1 flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] text-[var(--ink-muted)]">
{BUG_SEVERITY_SEGMENTS.map((segment) => (
<span key={segment.key}>{segment.label} {item[segment.key]}</span>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
})()}
</div>
{/* 健康趋势 - 暂时不显示 */}
{/* <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 max-w-[320px]">
@@ -968,27 +891,24 @@ export default function VersionDetailPage() {
);
}
function StatCard({ label, value, accent, warn }: { label: string; value: string | number; accent?: boolean; warn?: boolean }) {
const color = warn ? 'text-red-500' : accent ? 'text-[var(--accent)]' : 'text-[var(--ink)]';
function OverviewMetric({ label, value, icon, sub, tone }: {
label: string;
value: string | number;
icon?: JSX.Element;
sub?: string;
tone?: 'accent' | 'orange';
}) {
const valueColor = tone === 'orange' ? 'text-orange-600' : tone === 'accent' ? 'text-[var(--accent)]' : 'text-[var(--ink)]';
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 text-center">
<div className={`text-[18px] font-semibold tabular-nums ${color}`}>{value}</div>
<div className="text-[11px] text-[var(--ink-muted)] mt-0.5">{label}</div>
</div>
);
}
function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string; url?: string }) {
return (
<div className="flex items-center gap-2">
<span className="text-[var(--ink-muted)]">{icon}</span>
{url ? (
<a href={url} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
{label}<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-[12px] text-[var(--ink-muted)]">{label} · </span>
)}
<div className="min-w-0 border-b border-r border-[var(--line-soft)] p-3.5">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-[var(--ink-muted)]">
{icon && <span className="shrink-0 text-[var(--ink-muted)]">{icon}</span>}
<span>{label}</span>
</div>
<div className={`mt-1 truncate text-[14px] font-semibold tabular-nums ${valueColor}`} title={String(value)}>
{value}
</div>
{sub && <div className="mt-1 truncate text-[10px] text-[var(--ink-muted)]">{sub}</div>}
</div>
);
}