fix(version): 修正概览耗时与排名统计
This commit is contained in:
@@ -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 = versionTCs.length > 0 ? Math.round((executed / versionTCs.length) * 100) : 0;
|
||||||
const testPercent = Math.round((executed / versionTCs.length) * 100);
|
const allPassed = versionTCs.length > 0 && versionTCs.every((c) => c.status === 'passed');
|
||||||
const allPassed = versionTCs.every((c) => c.status === 'passed');
|
const hasRunning = versionTCs.some((c) => c.status === 'running');
|
||||||
const hasRunning = versionTCs.some((c) => c.status === 'running');
|
stageProgress['testing'] = {
|
||||||
stageProgress['testing'] = { percent: testPercent, status: allPassed ? 'done' : (hasRunning || executed > 0) ? 'active' : 'idle' };
|
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 = versionBugs.length > 0 ? Math.round((closedBugs / versionBugs.length) * 100) : 0;
|
||||||
const bugPercent = Math.round((closedBugs / versionBugs.length) * 100);
|
const allClosed = versionBugs.length > 0 && versionBugs.every((b) => b.status === 'closed' || b.status === 'rejected');
|
||||||
const allClosed = versionBugs.every((b) => b.status === 'closed' || b.status === 'rejected');
|
stageProgress['bug'] = {
|
||||||
stageProgress['bug'] = { percent: bugPercent, status: allClosed ? 'done' : closedBugs > 0 || versionBugs.length > 0 ? 'active' : 'idle' };
|
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">
|
{overdueDays > 0 && (
|
||||||
<span className="text-[var(--ink-muted)]">预计截止</span>
|
<div className="border-t border-red-100 bg-red-50 px-4 py-2 text-[12px] font-medium text-red-600">
|
||||||
<span className="text-[var(--ink)]">{deadline ?? '未设置'}</span>
|
实际截止晚于预计截止 {overdueDays} 天
|
||||||
</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>
|
</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>
|
<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,170 +659,116 @@ 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 maxHours = personalRanking[0]?.total || 1;
|
||||||
|
|
||||||
// 各阶段日历天数计算辅助函数
|
return (
|
||||||
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">
|
|
||||||
{/* 阶段日历耗时 */}
|
|
||||||
<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 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>
|
||||||
</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) => {
|
||||||
<div key={item.name}>
|
const totalWidth = (item.total / maxHours) * 100;
|
||||||
<div className="flex items-center gap-2 mb-1">
|
const actualWidth = item.total > 0 ? (item.actualHours / item.total) * 100 : 0;
|
||||||
<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>
|
const overtimeWidth = item.total > 0 ? (item.overtimeHours / item.total) * 100 : 0;
|
||||||
<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>
|
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>
|
||||||
<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>
|
</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]">
|
||||||
@@ -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>}
|
||||||
</div>
|
<span>{label}</span>
|
||||||
);
|
</div>
|
||||||
}
|
<div className={`mt-1 truncate text-[14px] font-semibold tabular-nums ${valueColor}`} title={String(value)}>
|
||||||
|
{value}
|
||||||
function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string; url?: string }) {
|
</div>
|
||||||
return (
|
{sub && <div className="mt-1 truncate text-[10px] text-[var(--ink-muted)]">{sub}</div>}
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<div className="mt-0.5 text-[10px] text-[var(--ink-muted)]">{statusLabel}</div>
|
||||||
|
</div>
|
||||||
|
<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}%
|
||||||
</span>
|
</span>
|
||||||
{status !== 'idle' && (
|
|
||||||
<span className={`text-[10px] font-medium leading-tight tabular-nums ${status === 'done' ? 'text-emerald-600' : 'text-blue-600'}`}>
|
|
||||||
{percent}%
|
|
||||||
</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>
|
||||||
<div className="h-full bg-blue-100 w-full">
|
<div className="mt-3 grid gap-2 text-[11px]">
|
||||||
<div className="h-full bg-blue-500 transition-all" style={{ width: `${percent}%` }} />
|
<div className="flex items-center justify-between gap-2">
|
||||||
</div>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
262
apps/web/lib/version-overview.test.ts
Normal file
262
apps/web/lib/version-overview.test.ts
Normal 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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
230
apps/web/lib/version-overview.ts
Normal file
230
apps/web/lib/version-overview.ts
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
16
apps/web/lib/work-hours.test.ts
Normal file
16
apps/web/lib/work-hours.test.ts
Normal 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.5h(2.5天)');
|
||||||
|
assert.equal(formatActualDuration(0.5), '0.5h(0.02天)');
|
||||||
|
assert.equal(formatActualDuration(0), '0h(0天)');
|
||||||
|
});
|
||||||
@@ -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 '0h(0天)';
|
||||||
|
return `${hours}h(${formatNaturalDays(hours)})`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仅天数版:"Y天"
|
* 仅天数版:"Y天"
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user