fix(version): 修正概览耗时与排名统计
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user