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 { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; 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 { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore'; import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { getVersionDetail } from '@/lib/derive'; import { getVersionDetail } from '@/lib/derive';
import { STAGES, Role } from '@/lib/stage'; import type { 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 { CapsuleStages } from '@/components/version/CapsuleStages'; import { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips'; import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health';
import { HealthTrend, generateMockTrend } from '@/components/version/HealthTrend'; import { CHANGE_REASON_LABEL } from '@/lib/requirement';
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 { OVERTIME_REASON_LABEL } from '@/lib/overtime'; import { OVERTIME_REASON_LABEL } from '@/lib/overtime';
import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab'; import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab';
import { PlanTab } from '@/components/version/PlanTab'; import { PlanTab } from '@/components/version/PlanTab';
@@ -26,22 +23,24 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore'; import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore'; 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 { hasPermission } from '@/lib/permissions';
import { calcWorkHours, formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
import { testCaseIntervals } from '@/lib/test-case';
import { bugIntervals } from '@/lib/bug';
import { planIntervals } from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format'; import { formatDateTime } from '@/lib/format';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector'; import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
const PRIORITY_STYLE: Record<string, string> = { function formatOverviewDateTime(value?: string | null): string {
P0: 'bg-red-500/10 text-red-600', if (!value) return '-';
P1: 'bg-orange-500/10 text-orange-600', return value.includes('T') ? formatDateTime(value) : value;
P2: 'bg-blue-500/10 text-blue-600', }
P3: 'bg-zinc-100 text-zinc-600',
P4: 'bg-zinc-100 text-zinc-500', 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 = [ const TABS = [
{ key: 'overview', label: '概览', permission: null as string | null }, { key: 'overview', label: '概览', permission: null as string | null },
@@ -111,32 +110,6 @@ export default function VersionDetailPage() {
} }
}, [plans, version, versionId, updateVersion]); }, [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) { if (!version) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3"> <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)]"> <div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
{activeTab === 'overview' ? ( {activeTab === 'overview' ? (
(() => { (() => {
const now = new Date();
const versionReqs = requirements.filter((r) => r.versionId === version.id); 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 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> = {}; 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 reasonRanking = Object.entries(reasonMap).sort((a, b) => b[1] - a[1]);
const reasonTotal = reasonRanking.reduce((s, [, v]) => s + v, 0) || 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 1. 风险详情 - 最上方 */} {/* 1. 风险详情 - 最上方 */}
@@ -297,33 +282,13 @@ export default function VersionDetailPage() {
</div> </div>
)} )}
{/* 2. 统计卡片 */} {/* 2. 阶段概览 */}
{(() => { {(() => {
const vPlans = plans.filter((p) => p.versionId === version.id); const researchPlans = versionPlans.filter((p) => p.type === 'research');
const pendingResearch = vPlans.filter((p) => p.type === 'research' && p.status !== 'completed').length; const productPlans = versionPlans.filter((p) => p.type === 'product');
const pendingProduct = vPlans.filter((p) => p.type === 'product' && p.status !== 'completed').length; const uiPlans = versionPlans.filter((p) => p.type === 'ui');
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>
);
})()}
{/* 3. 状态胶囊 */} const calcGroupProgress = (group: typeof versionPlans, type: 'research' | 'product' | 'ui') => {
{(() => {
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') => {
if (group.length === 0) return 0; if (group.length === 0) return 0;
// 分子=已完成子任务数,分母=所有plan的子任务总数 // 分子=已完成子任务数,分母=所有plan的子任务总数
// completed 的 plan其子任务全算已完成 // completed 的 plan其子任务全算已完成
@@ -354,120 +319,132 @@ export default function VersionDetailPage() {
return totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0; 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.length === 0) return 'idle';
if (group.every((p) => p.status === 'completed')) return 'done'; if (group.every((p) => p.status === 'completed')) return 'done';
if (group.some((p) => p.status === 'in_progress')) return 'active'; if (group.some((p) => p.status === 'in_progress')) return 'active';
return 'idle'; 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>> = {}; const stageProgress: Partial<Record<string, StageProgressItem>> = {};
if (researchPlans.length > 0) stageProgress['requirement'] = { percent: calcGroupProgress(researchPlans, 'research'), status: getPlanStatus(researchPlans) }; stageProgress['requirement'] = {
if (productPlans.length > 0) stageProgress['product_design'] = { percent: calcGroupProgress(productPlans, 'product'), status: getPlanStatus(productPlans) }; percent: calcGroupProgress(researchPlans, 'research'),
if (uiPlans.length > 0) stageProgress['ui_design'] = { percent: calcGroupProgress(uiPlans, 'ui'), status: getPlanStatus(uiPlans) }; 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 allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
const devProgress = calcDevTaskProgress(versionDevTasks); const hasActiveDevTask = versionDevTasks.some((t) => t.status === 'in_progress' || t.status === 'testing');
const allSubmitted = versionDevTasks.every((t) => t.status === 'submitted'); stageProgress['dev'] = {
const hasActive = versionDevTasks.some((t) => t.status === 'in_progress' || t.status === 'testing'); percent: versionDevTasks.length > 0 ? calcDevTaskProgress(versionDevTasks) : 0,
stageProgress['dev'] = { percent: devProgress, status: allSubmitted ? 'done' : hasActive ? 'active' : 'idle' }; 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 executed = versionTCs.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
const testPercent = Math.round((executed / versionTCs.length) * 100); const testPercent = versionTCs.length > 0 ? Math.round((executed / versionTCs.length) * 100) : 0;
const allPassed = versionTCs.every((c) => c.status === 'passed'); const allPassed = versionTCs.length > 0 && versionTCs.every((c) => c.status === 'passed');
const hasRunning = versionTCs.some((c) => c.status === 'running'); const hasRunning = versionTCs.some((c) => c.status === 'running');
stageProgress['testing'] = { percent: testPercent, status: allPassed ? 'done' : (hasRunning || executed > 0) ? 'active' : 'idle' }; stageProgress['testing'] = {
} percent: testPercent,
status: versionTCs.length === 0 ? 'idle' : allPassed ? 'done' : (hasRunning || executed > 0) ? 'active' : 'idle',
...stageEffortMetrics.testing,
};
// BUG 阶段已关闭Bug / 总Bug // BUG 阶段已关闭Bug / 总Bug
if (versionBugs.length > 0) {
const closedBugs = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length; const closedBugs = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length;
const bugPercent = Math.round((closedBugs / versionBugs.length) * 100); const bugPercent = versionBugs.length > 0 ? Math.round((closedBugs / versionBugs.length) * 100) : 0;
const allClosed = versionBugs.every((b) => b.status === 'closed' || b.status === 'rejected'); const allClosed = versionBugs.length > 0 && versionBugs.every((b) => b.status === 'closed' || b.status === 'rejected');
stageProgress['bug'] = { percent: bugPercent, status: allClosed ? 'done' : closedBugs > 0 || versionBugs.length > 0 ? 'active' : 'idle' }; stageProgress['bug'] = {
} percent: bugPercent,
status: versionBugs.length === 0 ? 'idle' : allClosed ? 'done' : 'active',
...stageEffortMetrics.bug,
};
return <CapsuleStages stageProgress={stageProgress as any} />; return <CapsuleStages stageProgress={stageProgress as any} />;
})()} })()}
{/* 4. 项目总耗时 + 参与人员 + 相关链接 */} {/* 3. 时间与投入概览 + 参与人员 + 相关链接 */}
<div className="space-y-4"> <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[] = []; const startDates: string[] = [];
vPlansAll.forEach((p) => { versionPlans.forEach((p) => {
if (p.actualStartAt) startDates.push(p.actualStartAt); 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); }); versionDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
vTCsAll.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); }); versionTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
const actualStart = startDates.length > 0 ? formatDateTime(startDates.sort()[0]) : (version.startDate ?? null); const actualStartIso = startDates.length > 0 ? startDates.sort()[0] : (version.startDate ?? null);
// 实际截止日期
const endDates: string[] = []; const endDates: string[] = [];
vPlansAll.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); }); versionPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
vDTs.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); }); versionDevTasks.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); });
vTCsAll.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); }); versionTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
vBugsAll.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); }); versionBugs.forEach((b) => {
const actualEnd = endDates.length > 0 ? formatDateTime(endDates.sort().reverse()[0]) : null; 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 deadline = version.expectedReleaseDate;
const versionActualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
let overdueDays = 0; let overdueDays = 0;
if (deadline && actualEnd) { if (deadline && isTerminalVersion && actualEndIso) {
overdueDays = Math.floor((new Date(actualEnd.replace(' ', 'T')).getTime() - new Date(deadline).getTime()) / (1000 * 60 * 60 * 24)); 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 metrics = [
const versionOTForTime = records.filter((r) => r.versionId === version.id); { label: '开始', value: actualStartIso ? formatOverviewDateTime(actualStartIso) : '未开始', icon: <Calendar className="h-3.5 w-3.5" /> },
const otHoursTotal = Math.round(versionOTForTime.reduce((sum, r) => sum + r.duration, 0) * 10) / 10; { 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 ( return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[13px]"> <div className="overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--bg-card)]">
<div className="flex items-center gap-1.5"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" /> {metrics.map((item) => (
<span className="text-[var(--ink-muted)]"></span> <OverviewMetric
<span className="text-[var(--ink)]">{actualStart ?? '未开始'}</span> key={item.label}
label={item.label}
value={item.value}
icon={item.icon}
sub={item.sub}
tone={item.tone}
/>
))}
</div> </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 && ( {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="border-t border-red-100 bg-red-50 px-4 py-2 text-[12px] font-medium text-red-600">
)} {overdueDays}
<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>
); );
})()} })()}
</div> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<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>
@@ -494,7 +471,7 @@ export default function VersionDetailPage() {
<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>
{(() => { {(() => {
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 typeLabel: Record<string, string> = { research: '调研', product: '产品方案', ui: 'UI设计' };
const grouped = ['research', 'product', 'ui'].map((type) => ({ const grouped = ['research', 'product', 'ui'].map((type) => ({
type, type,
@@ -682,171 +659,117 @@ export default function VersionDetailPage() {
); );
})()} })()}
{/* 阶段耗时 + 个人耗时排名 */} <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{/* 个人耗时排名 */}
{(() => { {(() => {
const versionPlans = plans.filter((p) => p.versionId === version.id);
// 各阶段日历天数计算辅助函数
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 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 ( 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="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="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="space-y-2.5"> <div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
{stages.map((s) => ( <div className="flex items-center gap-3 text-[10px] text-[var(--ink-muted)]">
<div key={s.label}> <span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-[var(--accent)]" /></span>
<div className="flex items-center justify-between mb-1"> <span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-orange-500" /></span>
<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> </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>
</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 ? ( {personalRanking.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"> <div className="space-y-2.5">
{personalRanking.slice(0, 8).map((item, i) => ( {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 key={item.name}>
<div className="flex items-center gap-2 mb-1"> <div className="mb-1 flex items-center gap-2">
<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 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="flex-1 text-[12px] text-[var(--ink)]">{item.name}</span> <span className="min-w-0 flex-1 truncate text-[12px] text-[var(--ink)]">{item.name}</span>
<span className="text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{item.total}h</span> <span className="shrink-0 text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{formatActualDuration(item.total)}</span>
</div> </div>
<div className="ml-7 flex items-center gap-0.5 h-1.5"> <div className="ml-7 h-2 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
{item.research > 0 && <div className="h-full rounded-full bg-orange-400" style={{ width: `${(item.research / maxHours) * 100}%` }} title={`调研 ${formatWorkHours(item.research)}`} />} <div className="flex h-full overflow-hidden rounded-full" style={{ width: `${totalWidth}%` }}>
{item.product > 0 && <div className="h-full rounded-full bg-pink-400" style={{ width: `${(item.product / maxHours) * 100}%` }} title={`产品 ${formatWorkHours(item.product)}`} />} {item.actualHours > 0 && <div className="h-full bg-[var(--accent)]" style={{ width: `${actualWidth}%` }} />}
{item.ui > 0 && <div className="h-full rounded-full bg-indigo-400" style={{ width: `${(item.ui / maxHours) * 100}%` }} title={`UI ${formatWorkHours(item.ui)}`} />} {item.overtimeHours > 0 && <div className="h-full bg-orange-500" style={{ width: `${overtimeWidth}%` }} />}
{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> </div>
))} <div className="ml-7 mt-1 flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] text-[var(--ink-muted)]">
<div className="flex items-center gap-3 pt-2 text-[10px] text-[var(--ink-muted)] flex-wrap"> <span> {formatActualDuration(item.actualHours)}</span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-orange-400" /></span> <span> {formatActualDuration(item.overtimeHours)}</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>
</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]"> {/* <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 max-w-[320px]">
<span className="text-[11px] font-medium text-[var(--ink-muted)] mb-2 block">健康趋势</span> <span className="text-[11px] font-medium text-[var(--ink-muted)] mb-2 block">健康趋势</span>
@@ -968,27 +891,24 @@ export default function VersionDetailPage() {
); );
} }
function StatCard({ label, value, accent, warn }: { label: string; value: string | number; accent?: boolean; warn?: boolean }) { function OverviewMetric({ label, value, icon, sub, tone }: {
const color = warn ? 'text-red-500' : accent ? 'text-[var(--accent)]' : 'text-[var(--ink)]'; 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 ( return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 text-center"> <div className="min-w-0 border-b border-r border-[var(--line-soft)] p-3.5">
<div className={`text-[18px] font-semibold tabular-nums ${color}`}>{value}</div> <div className="flex items-center gap-1.5 text-[11px] font-medium text-[var(--ink-muted)]">
<div className="text-[11px] text-[var(--ink-muted)] mt-0.5">{label}</div> {icon && <span className="shrink-0 text-[var(--ink-muted)]">{icon}</span>}
<span>{label}</span>
</div> </div>
); <div className={`mt-1 truncate text-[14px] font-semibold tabular-nums ${valueColor}`} title={String(value)}>
} {value}
</div>
function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string; url?: string }) { {sub && <div className="mt-1 truncate text-[10px] text-[var(--ink-muted)]">{sub}</div>}
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> </div>
); );
} }

View File

@@ -1,43 +1,60 @@
import type { Stage } from '@/lib/stage'; import type { Stage } from '@/lib/stage';
import { STAGES } from '@/lib/stage'; import { STAGES } from '@/lib/stage';
import { formatActualDuration } from '@/lib/work-hours';
export interface StageProgressItem { export interface StageProgressItem {
percent: number; percent: number;
status: 'idle' | 'active' | 'done'; status: 'idle' | 'active' | 'done';
actualHours?: number;
estimateHours?: number;
aiEstimateHours?: number;
showEstimates?: boolean;
} }
export function CapsuleStages({ stageProgress }: { export function CapsuleStages({ stageProgress }: {
stageProgress?: Partial<Record<Stage, StageProgressItem>>; stageProgress?: Partial<Record<Stage, StageProgressItem>>;
}) { }) {
return ( return (
<div className="flex rounded-lg border border-[var(--line)] overflow-hidden bg-[var(--bg-card)]"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-6">
{STAGES.map((stage, idx) => { {STAGES.map((stage) => {
const info = stageProgress?.[stage.key]; const info = stageProgress?.[stage.key];
const status = info?.status || 'idle'; const status = info?.status || 'idle';
const percent = info?.percent ?? 0; const percent = info?.percent ?? 0;
const actualHours = info?.actualHours ?? 0;
const stageLabel = stage.key === 'dev' ? '开发任务' : stage.label.replace(' ', '');
const statusLabel = status === 'done' ? '已完成' : status === 'active' ? '进行中' : '未开始';
const tone =
status === 'done'
? 'border-emerald-200 bg-emerald-50/40'
: status === 'active'
? 'border-blue-200 bg-blue-50/40'
: 'border-[var(--line)] bg-[var(--bg-card)]';
const progressClass = status === 'done' ? 'bg-emerald-500' : status === 'active' ? 'bg-blue-500' : 'bg-zinc-300';
return ( return (
<div <div
key={stage.key} key={stage.key}
className={`flex-1 flex flex-col ${idx < STAGES.length - 1 ? 'border-r border-[var(--line-soft)]' : ''}`} className={`min-h-[148px] rounded-lg border p-3 ${tone}`}
> >
<div className="flex items-center justify-between px-2 py-1.5 min-h-[28px]"> <div className="flex items-start justify-between gap-3">
<span className={`text-[10px] font-medium leading-tight ${status === 'active' ? 'text-[var(--ink)]' : status === 'done' ? 'text-[var(--ink-soft)]' : 'text-[var(--ink-muted)]'}`}> <div className="min-w-0">
{stage.label} <div className="truncate text-[12px] font-semibold text-[var(--ink)]">{stageLabel}</div>
</span> <div className="mt-0.5 text-[10px] text-[var(--ink-muted)]">{statusLabel}</div>
{status !== 'idle' && ( </div>
<span className={`text-[10px] font-medium leading-tight tabular-nums ${status === 'done' ? 'text-emerald-600' : 'text-blue-600'}`}> <span className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums ${status === 'done' ? 'bg-emerald-100 text-emerald-700' : status === 'active' ? 'bg-blue-100 text-blue-700' : 'bg-zinc-100 text-zinc-500'}`}>
{percent}% {percent}%
</span> </span>
)}
</div> </div>
<div className="h-[3px] w-full bg-zinc-50"> <div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-white/80">
{status === 'done' && <div className="h-full bg-emerald-500 w-full" />} <div className={`h-full rounded-full transition-all ${progressClass}`} style={{ width: `${status === 'done' ? 100 : percent}%` }} />
{status === 'active' && (
<div className="h-full bg-blue-100 w-full">
<div className="h-full bg-blue-500 transition-all" style={{ width: `${percent}%` }} />
</div> </div>
)} <div className="mt-3 grid gap-2 text-[11px]">
<div className="flex items-center justify-between gap-2">
<span className="text-[var(--ink-muted)]"></span>
<span className="font-medium tabular-nums text-[var(--ink)]">{formatActualDuration(actualHours)}</span>
</div>
{info?.showEstimates && <EstimateRow label="执行预估" hours={info.estimateHours} />}
{info?.showEstimates && <EstimateRow label="AI预估" hours={info.aiEstimateHours} accent />}
</div> </div>
</div> </div>
); );
@@ -45,3 +62,14 @@ export function CapsuleStages({ stageProgress }: {
</div> </div>
); );
} }
function EstimateRow({ label, hours, accent }: { label: string; hours?: number; accent?: boolean }) {
return (
<div className="flex items-center justify-between gap-2">
<span className="text-[var(--ink-muted)]">{label}</span>
<span className={`font-medium tabular-nums ${accent ? 'text-violet-600' : 'text-[var(--ink)]'}`}>
{hours !== undefined ? formatActualDuration(hours) : '-'}
</span>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import type { Priority } from './derive'; import type { Priority } from './derive';
import { calcWorkHours, type TimeInterval } from './work-hours'; import { calcActualElapsedHours, type TimeInterval } from './work-hours';
export type BugStatus = 'open' | 'fixing' | 'fixed' | 'verifying' | 'closed' | 'rejected'; export type BugStatus = 'open' | 'fixing' | 'fixed' | 'verifying' | 'closed' | 'rejected';
export type BugSeverity = 'critical' | 'major' | 'minor' | 'trivial'; export type BugSeverity = 'critical' | 'major' | 'minor' | 'trivial';
@@ -32,6 +32,8 @@ export interface Bug {
resolvedAt?: string; resolvedAt?: string;
closedAt?: string; closedAt?: string;
resolution?: string; resolution?: string;
estimateHours?: number;
aiEstimateHours?: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -93,7 +95,7 @@ export function getBugActualHours(bug: Bug, now: Date = new Date()): number {
const start = bug.createdAt; const start = bug.createdAt;
if (!start) return 0; if (!start) return 0;
const end = bug.closedAt ?? bug.resolvedAt ?? (bug.status === 'closed' || bug.status === 'rejected' ? bug.updatedAt : now.toISOString()); const end = bug.closedAt ?? bug.resolvedAt ?? (bug.status === 'closed' || bug.status === 'rejected' ? bug.updatedAt : now.toISOString());
return calcWorkHours(start, end); return calcActualElapsedHours(start, end);
} }
export function aggregateBugActualHours(bugs: Bug[], now: Date = new Date()): number { export function aggregateBugActualHours(bugs: Bug[], now: Date = new Date()): number {

View File

@@ -1,5 +1,5 @@
import type { Priority } from './derive'; import type { Priority } from './derive';
import { calcWorkHours, formatWorkHours, type TimeInterval } from './work-hours'; import { calcActualElapsedHours, calcWorkHours, formatWorkHours, type TimeInterval } from './work-hours';
export type DevTaskStatus = 'todo' | 'in_progress' | 'testing' | 'submitted'; export type DevTaskStatus = 'todo' | 'in_progress' | 'testing' | 'submitted';
@@ -114,8 +114,8 @@ function roundEffortHours(hours: number): number {
export function getActualHours(task: DevTask, now: Date = new Date()): number { export function getActualHours(task: DevTask, now: Date = new Date()): number {
if (!task.actualStartAt) return 0; if (!task.actualStartAt) return 0;
const end = task.actualEndAt ?? now.toISOString(); const end = task.actualEndAt ?? (task.status === 'submitted' ? task.updatedAt : now.toISOString());
return calcWorkHours(task.actualStartAt, end); return calcActualElapsedHours(task.actualStartAt, end);
} }
export interface AggregatedHours { export interface AggregatedHours {
@@ -167,7 +167,7 @@ export function devTaskIntervals(tasks: DevTask[], now: Date = new Date()): Time
const nowIso = now.toISOString(); const nowIso = now.toISOString();
for (const t of tasks) { for (const t of tasks) {
if (!t.actualStartAt) continue; if (!t.actualStartAt) continue;
out.push({ start: t.actualStartAt, end: t.actualEndAt ?? nowIso }); out.push({ start: t.actualStartAt, end: t.actualEndAt ?? (t.status === 'submitted' ? t.updatedAt : nowIso) });
} }
return out; return out;
} }

View File

@@ -1,7 +1,7 @@
import type { Priority } from './derive'; import type { Priority } from './derive';
import type { Reference } from './dev-task'; import type { Reference } from './dev-task';
import { DEFAULT_TEST_CATEGORY_ID } from './task-category'; import { DEFAULT_TEST_CATEGORY_ID } from './task-category';
import { calcWorkHours, type TimeInterval } from './work-hours'; import { calcActualElapsedHours, type TimeInterval } from './work-hours';
import { aggregateWorkEffort } from './work-effort-engine'; import { aggregateWorkEffort } from './work-effort-engine';
export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked'; export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked';
@@ -138,8 +138,9 @@ export function getTestCaseEstimateHours(tc: TestCase): number {
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number { export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
if (!tc.startedAt) return 0; if (!tc.startedAt) return 0;
const end = tc.completedAt ?? now.toISOString(); const isTerminal = tc.status === 'passed' || tc.status === 'failed' || tc.status === 'blocked';
return calcWorkHours(tc.startedAt, end); const end = tc.completedAt ?? (isTerminal ? tc.updatedAt : now.toISOString());
return calcActualElapsedHours(tc.startedAt, end);
} }
export function aggregateTestCaseHours(cases: TestCase[], now: Date = new Date()): { estimate: number; actual: number } { export function aggregateTestCaseHours(cases: TestCase[], now: Date = new Date()): { estimate: number; actual: number } {
@@ -166,7 +167,8 @@ export function testCaseIntervals(cases: TestCase[], now: Date = new Date()): Ti
const nowIso = now.toISOString(); const nowIso = now.toISOString();
for (const c of cases) { for (const c of cases) {
if (!c.startedAt) continue; if (!c.startedAt) continue;
out.push({ start: c.startedAt, end: c.completedAt ?? nowIso }); const isTerminal = c.status === 'passed' || c.status === 'failed' || c.status === 'blocked';
out.push({ start: c.startedAt, end: c.completedAt ?? (isTerminal ? c.updatedAt : nowIso) });
} }
return out; return out;
} }

View File

@@ -0,0 +1,262 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
calcBugSeverityRanking,
calcPersonalEffortRanking,
calcStageEffortMetrics,
calcVersionOverviewEffortTotals,
} from './version-overview';
import type { VersionPlan } from './version-plan';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { Bug } from './bug';
import type { OvertimeRecord } from './overtime';
function plan(patch: Partial<VersionPlan>): VersionPlan {
return {
id: patch.id || 'plan-1',
versionId: patch.versionId || 'v-1',
type: patch.type || 'research',
title: patch.title || '计划',
owner: patch.owner || 'Alice',
startTime: patch.startTime || '2026-06-22T09:00:00',
endTime: patch.endTime || '2026-06-22T18:00:00',
status: patch.status || 'completed',
actualStartAt: patch.actualStartAt,
completedAt: patch.completedAt,
createdAt: patch.createdAt || '2026-06-22T08:00:00',
addedBy: patch.addedBy || 'Alice',
};
}
function devTask(patch: Partial<DevTask>): DevTask {
return {
id: patch.id || 'dev-1',
taskNo: patch.taskNo || 'DEV-001',
requirementId: patch.requirementId || 'req-1',
title: patch.title || '开发任务',
categoryId: patch.categoryId || 'cat-1',
assigneeId: patch.assigneeId || 'Alice',
priority: patch.priority || 'P2',
expectedStartAt: patch.expectedStartAt || '2026-06-22T09:00:00',
expectedEndAt: patch.expectedEndAt || '2026-06-22T18:00:00',
estimateHours: patch.estimateHours,
aiEstimateHours: patch.aiEstimateHours,
actualStartAt: patch.actualStartAt,
actualEndAt: patch.actualEndAt,
status: patch.status || 'submitted',
isBlocked: patch.isBlocked ?? false,
createdBy: patch.createdBy || 'Alice',
createdAt: patch.createdAt || '2026-06-22T08:00:00',
updatedAt: patch.updatedAt || '2026-06-22T18:00:00',
};
}
function testCase(patch: Partial<TestCase>): TestCase {
return {
id: patch.id || 'tc-1',
caseNo: patch.caseNo || 'TC-001',
versionId: patch.versionId || 'v-1',
title: patch.title || '测试用例',
categoryId: patch.categoryId || 'cat-1',
priority: patch.priority || 'P2',
assigneeId: patch.assigneeId || 'Bob',
status: patch.status || 'passed',
estimateHours: patch.estimateHours,
aiEstimateHours: patch.aiEstimateHours,
startedAt: patch.startedAt,
completedAt: patch.completedAt,
createdBy: patch.createdBy || 'Bob',
createdAt: patch.createdAt || '2026-06-22T08:00:00',
updatedAt: patch.updatedAt || '2026-06-22T18:00:00',
};
}
function bug(patch: Partial<Bug>): Bug {
return {
id: patch.id || 'bug-1',
bugNo: patch.bugNo || 'BUG-001',
versionId: patch.versionId || 'v-1',
testCaseId: patch.testCaseId || 'tc-1',
title: patch.title || 'Bug',
description: patch.description || '描述',
severity: patch.severity || 'major',
priority: patch.priority || 'P1',
reportedBy: patch.reportedBy || 'QA',
assigneeId: patch.assigneeId || 'Bob',
status: patch.status || 'closed',
createdAt: patch.createdAt || '2026-06-22T10:00:00',
updatedAt: patch.updatedAt || '2026-06-22T12:00:00',
closedAt: patch.closedAt,
resolvedAt: patch.resolvedAt,
};
}
test('calcStageEffortMetrics returns actual hours and AI estimates per stage', () => {
const metrics = calcStageEffortMetrics({
plans: [
plan({ type: 'research', actualStartAt: '2026-06-22T09:00:00', completedAt: '2026-06-22T18:00:00' }),
plan({ type: 'product', actualStartAt: '2026-06-23T09:00:00', completedAt: '2026-06-23T12:00:00' }),
],
devTasks: [
devTask({
actualStartAt: '2026-06-24T09:00:00',
actualEndAt: '2026-06-24T14:00:00',
estimateHours: 4.5,
aiEstimateHours: 3.25,
}),
],
testCases: [
testCase({
startedAt: '2026-06-25T09:00:00',
completedAt: '2026-06-25T11:00:00',
estimateHours: 2.25,
aiEstimateHours: 1.5,
}),
],
bugs: [
bug({ createdAt: '2026-06-26T10:00:00', closedAt: '2026-06-26T10:10:00' }),
],
});
assert.equal(metrics.requirement.actualHours, 9);
assert.equal(metrics.product_design.actualHours, 3);
assert.equal(metrics.dev.actualHours, 5);
assert.equal(metrics.dev.estimateHours, 4.5);
assert.equal(metrics.dev.aiEstimateHours, 3.25);
assert.equal(metrics.testing.actualHours, 2);
assert.equal(metrics.testing.estimateHours, 2.25);
assert.equal(metrics.testing.aiEstimateHours, 1.5);
assert.equal(metrics.bug.actualHours, 0.5);
});
test('calcStageEffortMetrics uses updatedAt for terminal test cases missing completedAt', () => {
const metrics = calcStageEffortMetrics({
plans: [],
devTasks: [],
testCases: [
testCase({
status: 'passed',
startedAt: '2026-06-25T09:00:00',
completedAt: undefined,
updatedAt: '2026-06-25T09:20:00',
}),
],
bugs: [],
now: new Date('2026-06-26T18:00:00'),
});
assert.equal(metrics.testing.actualHours, 0.5);
});
test('calcVersionOverviewEffortTotals sums actual hours and overtime records separately', () => {
const overtimeRecords: OvertimeRecord[] = [
{
id: 'ot-1',
projectId: 'project-1',
versionId: 'v-1',
person: 'Alice',
startTime: '2026-06-22T19:00:00',
endTime: '2026-06-22T20:15:00',
duration: 1.25,
reasonId: 'reason-3',
createdAt: '2026-06-22T20:20:00',
},
{
id: 'ot-2',
projectId: 'project-1',
versionId: 'v-1',
person: 'Bob',
startTime: '2026-06-22T20:00:00',
endTime: '2026-06-22T22:03:00',
duration: 2.05,
reasonId: 'reason-4',
createdAt: '2026-06-22T22:10:00',
},
];
const totals = calcVersionOverviewEffortTotals({
plans: [plan({ actualStartAt: '2026-06-22T09:00:00', completedAt: '2026-06-22T18:00:00' })],
devTasks: [devTask({ actualStartAt: '2026-06-22T10:00:00', actualEndAt: '2026-06-22T12:00:00' })],
testCases: [],
bugs: [],
overtimeRecords,
});
assert.equal(totals.actualHours, 11);
assert.equal(totals.overtimeHours, 3.3);
});
test('calcPersonalEffortRanking includes bug work and sorts by total hours', () => {
const overtimeRecords: OvertimeRecord[] = [
{
id: 'ot-1',
projectId: 'project-1',
versionId: 'v-1',
person: 'Alice',
startTime: '2026-06-23T19:00:00',
endTime: '2026-06-23T20:30:00',
duration: 1.5,
reasonId: 'reason-3',
createdAt: '2026-06-23T20:35:00',
},
{
id: 'ot-2',
projectId: 'project-1',
versionId: 'v-1',
person: 'Bob',
startTime: '2026-06-25T19:00:00',
endTime: '2026-06-25T21:00:00',
duration: 2,
reasonId: 'reason-4',
createdAt: '2026-06-25T21:10:00',
},
];
const ranking = calcPersonalEffortRanking({
plans: [plan({ owner: 'Alice', actualStartAt: '2026-06-22T09:00:00', completedAt: '2026-06-22T18:00:00' })],
devTasks: [devTask({ assigneeId: 'Alice', actualStartAt: '2026-06-23T09:00:00', actualEndAt: '2026-06-23T11:00:00' })],
testCases: [testCase({ assigneeId: 'Bob', startedAt: '2026-06-24T09:00:00', completedAt: '2026-06-24T11:00:00' })],
bugs: [bug({ assigneeId: 'Bob', createdAt: '2026-06-25T10:00:00', closedAt: '2026-06-25T12:00:00' })],
overtimeRecords,
});
assert.equal(ranking[0].name, 'Alice');
assert.equal(ranking[0].actualHours, 11);
assert.equal(ranking[0].overtimeHours, 1.5);
assert.equal(ranking[0].total, 12.5);
assert.equal(ranking[1].name, 'Bob');
assert.equal(ranking[1].actualHours, 4);
assert.equal(ranking[1].overtimeHours, 2);
assert.equal(ranking[1].total, 6);
});
test('calcBugSeverityRanking groups bugs by assignee and severity', () => {
const ranking = calcBugSeverityRanking([
bug({ id: 'bug-1', assigneeId: 'Alice', severity: 'critical' }),
bug({ id: 'bug-2', assigneeId: 'Alice', severity: 'major' }),
bug({ id: 'bug-3', assigneeId: 'Alice', severity: 'minor' }),
bug({ id: 'bug-4', assigneeId: 'Bob', severity: 'critical' }),
bug({ id: 'bug-5', assigneeId: 'Bob', severity: 'trivial' }),
bug({ id: 'bug-6', assigneeId: 'Alice', severity: 'trivial' }),
]);
assert.deepEqual(ranking, [
{
assigneeId: 'Alice',
critical: 1,
major: 1,
minor: 1,
trivial: 1,
total: 4,
},
{
assigneeId: 'Bob',
critical: 1,
major: 0,
minor: 0,
trivial: 1,
total: 2,
},
]);
});

View File

@@ -0,0 +1,230 @@
import type { Stage } from './stage';
import type { VersionPlan } from './version-plan';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { Bug, BugSeverity } from './bug';
import type { OvertimeRecord } from './overtime';
import { getActualHours as getDevTaskActualHours } from './dev-task';
import { getTestCaseActualHours } from './test-case';
import { getBugActualHours } from './bug';
import { calcActualElapsedHours } from './work-hours';
export interface StageEffortMetric {
actualHours: number;
estimateHours?: number;
aiEstimateHours?: number;
showEstimates?: boolean;
}
export interface PersonalEffortItem {
name: string;
actualHours: number;
overtimeHours: number;
total: number;
}
export interface BugSeverityRankingItem {
assigneeId: string;
critical: number;
major: number;
minor: number;
trivial: number;
total: number;
}
export interface VersionOverviewEffortTotals {
actualHours: number;
overtimeHours: number;
}
function roundHalf(hours: number): number {
return Math.round(hours * 2) / 2;
}
function roundTenth(hours: number): number {
return Math.round(hours * 10) / 10;
}
function roundHundredth(hours: number): number {
return Math.round(hours * 100) / 100;
}
function sumAiEstimate<T>(items: T[], getValue: (item: T) => number | undefined): number | undefined {
const total = items.reduce((sum, item) => {
const value = getValue(item);
return sum + (typeof value === 'number' && value > 0 ? value : 0);
}, 0);
return total > 0 ? roundHundredth(total) : undefined;
}
function sumEstimate<T>(items: T[], getValue: (item: T) => number | undefined): number | undefined {
const total = items.reduce((sum, item) => {
const value = getValue(item);
return sum + (typeof value === 'number' && value > 0 ? value : 0);
}, 0);
return total > 0 ? roundHundredth(total) : undefined;
}
function getPlanActualHours(plan: VersionPlan, now: Date): number {
if (!plan.actualStartAt) return 0;
return calcActualElapsedHours(plan.actualStartAt, plan.completedAt ?? now.toISOString());
}
function sumActualHours<T>(items: T[], getValue: (item: T) => number): number {
return roundHalf(items.reduce((sum, item) => sum + getValue(item), 0));
}
export function calcStageEffortMetrics(input: {
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
now?: Date;
}): Record<Stage, StageEffortMetric> {
const now = input.now ?? new Date();
const researchPlans = input.plans.filter((p) => p.type === 'research');
const productPlans = input.plans.filter((p) => p.type === 'product');
const uiPlans = input.plans.filter((p) => p.type === 'ui');
return {
requirement: {
actualHours: sumActualHours(researchPlans, (plan) => getPlanActualHours(plan, now)),
},
product_design: {
actualHours: sumActualHours(productPlans, (plan) => getPlanActualHours(plan, now)),
},
ui_design: {
actualHours: sumActualHours(uiPlans, (plan) => getPlanActualHours(plan, now)),
},
dev: {
actualHours: sumActualHours(input.devTasks, (task) => getDevTaskActualHours(task, now)),
estimateHours: sumEstimate(input.devTasks, (task) => task.estimateHours),
aiEstimateHours: sumAiEstimate(input.devTasks, (task) => task.aiEstimateHours),
showEstimates: true,
},
testing: {
actualHours: sumActualHours(input.testCases, (testCase) => getTestCaseActualHours(testCase, now)),
estimateHours: sumEstimate(input.testCases, (testCase) => testCase.estimateHours),
aiEstimateHours: sumAiEstimate(input.testCases, (testCase) => testCase.aiEstimateHours),
showEstimates: true,
},
bug: {
actualHours: sumActualHours(input.bugs, (bug) => getBugActualHours(bug, now)),
estimateHours: sumEstimate(input.bugs, (bug) => bug.estimateHours),
aiEstimateHours: sumAiEstimate(input.bugs, (bug) => bug.aiEstimateHours),
showEstimates: true,
},
};
}
export function calcVersionOverviewEffortTotals(input: {
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
overtimeRecords: OvertimeRecord[];
now?: Date;
}): VersionOverviewEffortTotals {
const now = input.now ?? new Date();
const planHours = sumActualHours(input.plans, (plan) => getPlanActualHours(plan, now));
const devHours = sumActualHours(input.devTasks, (task) => getDevTaskActualHours(task, now));
const testHours = sumActualHours(input.testCases, (testCase) => getTestCaseActualHours(testCase, now));
const bugHours = sumActualHours(input.bugs, (bug) => getBugActualHours(bug, now));
const actualHours = roundHalf(planHours + devHours + testHours + bugHours);
const overtimeHours = roundTenth(input.overtimeRecords.reduce((sum, record) => sum + record.duration, 0));
return { actualHours, overtimeHours };
}
export function calcPersonalEffortRanking(input: {
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
overtimeRecords?: OvertimeRecord[];
now?: Date;
}): PersonalEffortItem[] {
const now = input.now ?? new Date();
const personalHours = new Map<string, { actualHours: number; overtimeHours: number }>();
const addActualHours = (name: string | undefined, hours: number) => {
if (!name || hours <= 0) return;
const prev = personalHours.get(name) || { actualHours: 0, overtimeHours: 0 };
prev.actualHours += hours;
personalHours.set(name, prev);
};
const addOvertimeHours = (name: string | undefined, hours: number) => {
if (!name || hours <= 0) return;
const prev = personalHours.get(name) || { actualHours: 0, overtimeHours: 0 };
prev.overtimeHours += hours;
personalHours.set(name, prev);
};
input.plans.forEach((plan) => {
addActualHours(plan.owner, getPlanActualHours(plan, now));
});
input.devTasks.forEach((task) => {
addActualHours(task.assigneeId, getDevTaskActualHours(task, now));
});
input.testCases.forEach((testCase) => {
addActualHours(testCase.assigneeId, getTestCaseActualHours(testCase, now));
});
input.bugs.forEach((bug) => {
addActualHours(bug.assigneeId, getBugActualHours(bug, now));
});
(input.overtimeRecords ?? []).forEach((record) => {
addOvertimeHours(record.person, record.duration);
});
return Array.from(personalHours.entries())
.map(([name, hours]) => {
const actualHours = roundHalf(hours.actualHours);
const overtimeHours = roundTenth(hours.overtimeHours);
return {
name,
actualHours,
overtimeHours,
total: roundTenth(actualHours + overtimeHours),
};
})
.sort((a, b) => b.total - a.total);
}
function createBugSeverityRankingItem(assigneeId: string): BugSeverityRankingItem {
return {
assigneeId,
critical: 0,
major: 0,
minor: 0,
trivial: 0,
total: 0,
};
}
export function calcBugSeverityRanking(bugs: Bug[]): BugSeverityRankingItem[] {
const rows = new Map<string, BugSeverityRankingItem>();
const severityOrder: BugSeverity[] = ['critical', 'major', 'minor', 'trivial'];
bugs.forEach((bug) => {
if (!bug.assigneeId || !severityOrder.includes(bug.severity)) return;
const row = rows.get(bug.assigneeId) ?? createBugSeverityRankingItem(bug.assigneeId);
row[bug.severity] += 1;
row.total += 1;
rows.set(bug.assigneeId, row);
});
return Array.from(rows.values()).sort(
(a, b) =>
b.total - a.total ||
b.critical - a.critical ||
b.major - a.major ||
b.minor - a.minor ||
b.trivial - a.trivial ||
a.assigneeId.localeCompare(b.assigneeId),
);
}

View File

@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { calcActualElapsedHours, formatActualDuration } from './work-hours';
test('calcActualElapsedHours uses real elapsed time with a half-hour minimum', () => {
assert.equal(calcActualElapsedHours('2026-06-22T09:00:00', '2026-06-22T09:10:00'), 0.5);
assert.equal(calcActualElapsedHours('2026-06-22T09:00:00', '2026-06-22T10:15:00'), 1.5);
assert.equal(calcActualElapsedHours('2026-06-22T10:00:00', '2026-06-22T09:00:00'), 0);
});
test('formatActualDuration converts hours to natural days', () => {
assert.equal(formatActualDuration(60.5), '60.5h2.5天)');
assert.equal(formatActualDuration(0.5), '0.5h0.02天)');
assert.equal(formatActualDuration(0), '0h0天');
});

View File

@@ -94,6 +94,36 @@ export function formatWorkHoursShort(hours: number): string {
return `${hours}h`; return `${hours}h`;
} }
/**
* 计算真实经过时长,而不是工作时段内时长。
*
* 用于“实际耗时”口径:按开始/结束时间戳直接相减,精度 0.5h
* 只要有正向耗时,最低按 0.5h 计,避免 30 分钟内工作被显示为 0。
*/
export function calcActualElapsedHours(startISO?: string | null, endISO?: string | null): number {
if (!startISO || !endISO) return 0;
const start = new Date(startISO).getTime();
const end = new Date(endISO).getTime();
if (isNaN(start) || isNaN(end) || end <= start) return 0;
const hours = (end - start) / MS_PER_HOUR;
return Math.max(0.5, Math.round(hours * 2) / 2);
}
function formatNaturalDays(hours: number): string {
if (!isFinite(hours) || hours <= 0) return '0天';
const days = hours / 24;
if (days < 0.1) return `${Number(days.toFixed(2))}`;
return `${Number.isInteger(days) ? String(days) : days.toFixed(1).replace(/\.0$/, '')}`;
}
/**
* 实际耗时显示,天数按自然日 24h 换算。
*/
export function formatActualDuration(hours: number): string {
if (!isFinite(hours) || hours <= 0) return '0h0天';
return `${hours}h${formatNaturalDays(hours)}`;
}
/** /**
* 仅天数版:"Y天" * 仅天数版:"Y天"
*/ */