Files
ftb-project-management/apps/web/app/versions/[id]/page.tsx
Script Generator 0ed7ff1d53 refactor: 版本概览重新排版
1. 去掉顶部 Tag Row(P0/进行中/规划中/100健康/负责人缺失等标签)
   因为状态胶囊已经展示了阶段信息

2. 概览内容重新排列:
   - 风险详情(最上方)
   - 统计卡片:关联需求/待调研/待方案设计/待开发/开发中/未关闭Bug/加班时长
   - 状态胶囊
   - 项目总耗时 + 参与人员 + 相关链接
   - 加班时长排名 + 加班原因占比
   - 阶段耗时 + 个人耗时排名
   - 健康趋势(注释掉,代码保留)

3. 统计卡片去掉"开发任务"和"测试用例"
   新增"待调研"和"待方案设计"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 13:19:52 +08:00

706 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ChevronLeft, Package, Calendar, Clock, ExternalLink, FileText, Palette, Layout } 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 } 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 { 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 } from '@/lib/requirement';
import { OVERTIME_REASON_LABEL } from '@/lib/overtime';
import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab';
import { PlanTab } from '@/components/version/PlanTab';
import { DevTaskTab } from '@/components/dev-task/DevTaskTab';
import { TestCaseTab } from '@/components/test-case/TestCaseTab';
import { BugTab } from '@/components/bug/BugTab';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { calcGroupProgress as calcDevTaskProgress, calcActualHoursByDates } from '@/lib/dev-task';
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',
};
const TABS = [
{ key: 'overview', label: '概览' },
{ key: 'requirements', label: '关联需求' },
{ key: 'research', label: '调研' },
{ key: 'product', label: '产品方案' },
{ key: 'ui', label: 'UI设计' },
{ key: 'tasks', label: '开发任务' },
{ key: 'testcases', label: '测试用例' },
{ key: 'bugs', label: 'BUG' },
];
export default function VersionDetailPage() {
const params = useParams();
const router = useRouter();
const versionId = params.id as string;
const { overview, fetchOverview, updateVersion, deleteVersion } = useProductStore();
const { requirements, fetchRequirements, updateRequirement } = useRequirementStore();
const { records, fetchRecords } = useOvertimeStore();
const { plans, fetchPlans, createPlan, updatePlan, completePlan, deletePlan } = useVersionPlanStore();
const { tasks: devTasks, fetchTasks: fetchDevTasks, deleteTask: deleteDevTask } = useDevTaskStore();
const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore();
const { bugs, fetchBugs, deleteBug } = useBugStore();
const user = useAuthStore((s) => s.user);
const [activeTab, setActiveTab] = useState('overview');
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
useEffect(() => {
if (!version || !plans.length) return;
if (version.status !== 'planned' && version.status !== 'developing') return;
const today = new Date().toISOString().slice(0, 10);
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
const stageOrder: string[] = ['requirement', 'product_design', 'ui_design'];
const versionPlans = plans.filter((p) => p.versionId === versionId && p.startTime <= today);
if (versionPlans.length === 0) return;
let targetStage = '';
for (const p of versionPlans) {
const s = stageMap[p.type];
if (!targetStage || stageOrder.indexOf(s) > stageOrder.indexOf(targetStage)) {
targetStage = s;
}
}
if (version.status === 'planned' || (version.currentStage && stageOrder.indexOf(targetStage) > stageOrder.indexOf(version.currentStage))) {
updateVersion(version.productId, version.id, { status: 'developing', currentStage: targetStage as any });
}
}, [plans, version, versionId, updateVersion]);
const elapsedDays = useMemo(() => {
if (!version?.startDate) return 0;
const start = new Date(version.startDate);
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?.startDate]);
if (!version) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-[var(--ink-muted)]"></p>
<button onClick={() => router.push('/versions')} className="text-xs text-[var(--accent)] hover:underline"></button>
</div>
);
}
const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress);
const healthLevel = getHealthLevel(healthScore);
const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members);
const renderActions = () => {
const buttons: { label: string; action: () => void; danger?: boolean }[] = [];
if (version.status === 'planned') {
buttons.push({ label: '删除', action: () => {
if (confirm('确认删除该版本关联的需求会回到需求池版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
// 释放关联需求
requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, { versionId: undefined, addedToVersionBy: undefined }));
// 清理计划任务
plans.filter((p) => p.versionId === version.id).forEach((p) => deletePlan(p.id));
// 清理开发任务
const versionReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.id));
devTasks.filter((t) => versionReqIds.has(t.requirementId)).forEach((t) => deleteDevTask(t.id));
// 清理测试用例和Bug
testCases.filter((c) => c.versionId === version.id).forEach((c) => deleteTestCase(c.id));
bugs.filter((b) => b.versionId === version.id).forEach((b) => deleteBug(b.id));
deleteVersion(version.productId, version.id);
router.push('/versions');
}
}, danger: true });
buttons.push({ label: '关闭', action: () => updateVersion(version.productId, version.id, { status: 'closed' }), danger: true });
} else if (version.status === 'developing') {
buttons.push({ label: '暂停', action: () => updateVersion(version.productId, version.id, { status: 'paused' }) });
buttons.push({ label: '关闭', action: () => updateVersion(version.productId, version.id, { status: 'closed' }), danger: true });
} else if (version.status === 'paused') {
buttons.push({ label: '恢复', action: () => updateVersion(version.productId, version.id, { status: 'developing' }) });
buttons.push({ label: '关闭', action: () => updateVersion(version.productId, version.id, { status: 'closed' }), danger: true });
}
return buttons.map((btn) => (
<button
key={btn.label}
onClick={btn.action}
className={`h-7 px-3 rounded-md text-[12px] font-medium border transition-colors ${btn.danger ? 'border-red-200 text-red-600 hover:bg-red-50' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{btn.label}
</button>
));
};
return (
<div className="flex h-full flex-col">
{/* Header */}
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="flex items-center">
<button onClick={() => router.push('/versions')} className="flex items-center gap-1 rounded-md px-1.5 py-1 text-[12px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]">
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<span className="ml-2 text-[var(--ink-muted)]">/</span>
<button onClick={() => router.push('/products')} className="ml-2 text-[12px] text-[var(--ink-muted)] hover:text-[var(--accent)] hover:underline">{version.productName}</button>
{version.projectName && version.projectName !== '未关联' && (
<>
<span className="ml-1.5 text-[var(--ink-muted)]">/</span>
<span className="ml-1.5 text-[12px] text-[var(--ink-muted)]">{version.projectName}</span>
</>
)}
<span className="ml-1.5 text-[var(--ink-muted)]">/</span>
<span className="ml-1.5 text-[15px] font-semibold text-[var(--ink)]">{version.name}</span>
</div>
<div className="flex items-center gap-2">{renderActions()}</div>
</header>
{/* Tab bar */}
<div className="flex items-center gap-0 border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2.5 text-[13px] font-medium border-b-2 transition-colors ${activeTab === tab.key ? 'border-[var(--accent)] text-[var(--ink)]' : 'border-transparent text-[var(--ink-muted)] hover:text-[var(--ink-soft)]'}`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
{activeTab === 'overview' ? (
(() => {
const versionReqs = requirements.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 personOT: Record<string, number> = {};
versionOT.forEach((r) => { personOT[r.person] = (personOT[r.person] || 0) + r.duration; });
const otRanking = Object.entries(personOT).sort((a, b) => b[1] - a[1]).map(([name, hours]) => ({ name, hours: Math.round(hours * 10) / 10 }));
// 加班原因占比
const reasonMap: Record<string, number> = {};
versionOT.forEach((r) => { reasonMap[r.reasonId] = (reasonMap[r.reasonId] || 0) + r.duration; });
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').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. 风险详情 - 最上方 */}
{riskTags.length > 0 && (
<div className="rounded-xl border border-orange-200 bg-orange-50/40 p-4 max-h-[200px] overflow-y-auto">
<div className="flex items-center gap-2 mb-3">
<span className="text-[12px] font-semibold text-orange-700"></span>
<span className="text-[10px] text-orange-600"> {healthScore} · {HEALTH_LEVEL_LABEL[healthLevel]}</span>
</div>
<div className="space-y-2">
{riskTags.map((tag) => (
<div key={tag.key} className="flex gap-2.5 pb-2 border-b border-orange-100 last:border-b-0 last:pb-0">
<span className={`shrink-0 inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium h-fit mt-0.5 ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
<div className="flex-1 space-y-0.5 text-[11px]">
{tag.reason && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.reason}</div>}
{tag.suggestion && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.suggestion}</div>}
</div>
</div>
))}
</div>
</div>
)}
{/* 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-7">
<StatCard label="关联需求" value={versionReqs.length} />
<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 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 (type === 'research') {
const totals = group.reduce((acc, p) => {
const tasks = p.tasks || [];
acc.total += tasks.length;
acc.done += tasks.filter((t) => t.status === 'completed').length;
return acc;
}, { total: 0, done: 0 });
return totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0;
}
const totals = group.reduce((acc, p) => {
const linked = p.linkedRequirementIds || [];
const completed = p.completedRequirementIds || [];
acc.total += linked.length;
acc.done += completed.filter((id) => linked.includes(id)).length;
return acc;
}, { total: 0, done: 0 });
return totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0;
};
const getPlanStatus = (group: typeof vPlans): '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' };
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) };
// 开发阶段
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' };
}
// 测试阶段
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' };
}
// 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' };
}
return <CapsuleStages stageProgress={stageProgress as any} />;
})()}
{/* 4. 项目总耗时 + 参与人员 + 相关链接 */}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-4">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center gap-4 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)]">{version.startDate ?? '未设置'}</span>
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{version.expectedReleaseDate ?? '未设置'}</span>
</div>
<div className="flex items-center gap-1.5 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="font-medium text-[var(--ink)]">{elapsedDays}</span>
</div>
</div>
</div>
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-2 font-medium"></div>
{(() => {
const planMembers = plans
.filter((p) => p.versionId === version.id)
.map((p) => ({ role: p.type === 'research' ? 'research' as const : p.type === 'product' ? 'product' as const : 'ui' as const, name: p.owner }));
const roleLabel: Record<string, string> = { research: '调研', product: '产品', ui: 'UI' };
const seen = new Set<string>();
const dedupPlanMembers = planMembers.filter((m) => {
const key = `${m.role}:${m.name}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const existingKeys = new Set((version.members ?? []).map((m) => `${m.role}:${m.name}`));
const extraMembers = dedupPlanMembers.filter((m) => !existingKeys.has(`${m.role}:${m.name}`));
const allMembers = [...(version.members ?? []), ...extraMembers];
if (allMembers.length === 0) return <span className="text-[12px] text-[var(--ink-muted)]"></span>;
return (
<div className="flex flex-wrap gap-1.5">
{allMembers.map((m, i) => (
<span key={`${m.name}-${i}`} className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-[11px] text-[var(--ink-soft)]">
<span className="text-[var(--ink-muted)]">{roleLabel[m.role] ?? m.role}</span>
<span className="font-medium text-[var(--ink)]">{m.name}</span>
</span>
))}
</div>
);
})()}
</div>
</div>
<div className="col-span-1">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 h-full">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
<div className="space-y-3">
<LinkItem icon={<FileText className="h-3.5 w-3.5" />} label="调研报告" url={version.links?.research} />
<LinkItem icon={<Layout className="h-3.5 w-3.5" />} label="原型地址" url={version.links?.prototype} />
<LinkItem icon={<Palette className="h-3.5 w-3.5" />} label="UI设计稿" url={version.links?.ui} />
</div>
</div>
</div>
</div>
{/* 5. 加班时长排名 + 加班原因占比 */}
<div className="grid grid-cols-2 gap-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>
{otRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="space-y-2">
{otRanking.slice(0, 8).map((item, i) => (
<div key={item.name} className="flex items-center gap-2">
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold ${i < 3 ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'bg-[var(--bg-subtle)] text-[var(--ink-muted)]'}`}>{i + 1}</span>
<span className="flex-1 text-[12px] text-[var(--ink)]">{item.name}</span>
<span className="text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{item.hours}h</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>
{reasonRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="space-y-2.5">
{reasonRanking.map(([reasonId, hours]) => {
const percent = Math.round((hours / reasonTotal) * 100);
const reasonName = OVERTIME_REASON_LABEL[reasonId] || reasonId;
return (
<div key={reasonId}>
<div className="flex items-center justify-between mb-1">
<span className="text-[12px] text-[var(--ink-soft)]">{reasonName}</span>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{percent}%</span>
</div>
<div className="h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${percent}%` }} />
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* 阶段耗时 + 个人耗时排名 */}
{(() => {
const versionPlans = plans.filter((p) => p.versionId === version.id);
// 各阶段日历天数计算辅助函数
const calcCalendar = (starts: string[], ends: string[]) => {
const sortedStarts = starts.filter(Boolean).sort();
const sortedEnds = ends.filter(Boolean).sort().reverse();
const start = sortedStarts[0] || null;
const end = sortedEnds[0] || null;
const days = start && end ? Math.max(1, Math.ceil((new Date(end).getTime() - new Date(start).getTime()) / (1000 * 60 * 60 * 24)) + 1) : 0;
return { start, end, days };
};
// 调研阶段
const researchPlans = versionPlans.filter((p) => p.type === 'research');
const researchCal = calcCalendar(
researchPlans.filter((p) => p.actualStartAt).map((p) => p.actualStartAt!),
researchPlans.filter((p) => p.completedAt).map((p) => p.completedAt!),
);
// 产品方案阶段
const productPlans = versionPlans.filter((p) => p.type === 'product');
const productCal = calcCalendar(
productPlans.filter((p) => p.actualStartAt).map((p) => p.actualStartAt!),
productPlans.filter((p) => p.completedAt).map((p) => p.completedAt!),
);
// UI设计阶段
const uiPlans = versionPlans.filter((p) => p.type === 'ui');
const uiCal = calcCalendar(
uiPlans.filter((p) => p.actualStartAt).map((p) => p.actualStartAt!),
uiPlans.filter((p) => p.completedAt).map((p) => p.completedAt!),
);
// 开发阶段
const devCal = calcCalendar(
versionDevTasks.filter((t) => t.startDate).map((t) => t.startDate!),
versionDevTasks.filter((t) => t.completedAt).map((t) => t.completedAt!),
);
// 测试阶段
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 = calcActualHoursByDates(p.actualStartAt, p.completedAt);
addHours(p.owner, p.type === 'research' ? 'research' : p.type === 'product' ? 'product' : 'ui', hours);
});
versionDevTasks.forEach((t) => {
if (!t.startDate || !t.assigneeId) return;
addHours(t.assigneeId, 'dev', calcActualHoursByDates(t.startDate, t.completedAt));
});
versionTCs.forEach((c) => {
if (!c.startedAt || !c.assigneeId) return;
addHours(c.assigneeId, 'test', calcActualHoursByDates(c.startedAt, c.completedAt));
});
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);
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="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)]">
<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)]">{totalHours}h{Math.round(totalHours / 8)}</span>
</div>
</div>
</div>
</div>
{/* 个人耗时排名 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
{personalRanking.length === 0 ? (
<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>
<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={`调研 ${item.research}h`} />}
{item.product > 0 && <div className="h-full rounded-full bg-pink-400" style={{ width: `${(item.product / maxHours) * 100}%` }} title={`产品 ${item.product}h`} />}
{item.ui > 0 && <div className="h-full rounded-full bg-indigo-400" style={{ width: `${(item.ui / maxHours) * 100}%` }} title={`UI ${item.ui}h`} />}
{item.dev > 0 && <div className="h-full rounded-full bg-blue-400" style={{ width: `${(item.dev / maxHours) * 100}%` }} title={`开发 ${item.dev}h`} />}
{item.test > 0 && <div className="h-full rounded-full bg-purple-400" style={{ width: `${(item.test / maxHours) * 100}%` }} title={`测试 ${item.test}h`} />}
</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 className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 max-w-[320px]">
<span className="text-[11px] font-medium text-[var(--ink-muted)] mb-2 block">健康趋势</span>
<HealthTrend data={generateMockTrend(healthScore, 7)} />
</div> */}
</div>
);
})()
) : activeTab === 'requirements' ? (
<VersionRequirementsTab
versionId={version.id}
projectId={version.projectId}
requirements={requirements}
currentUserName={user?.name ?? ''}
onLink={(ids, addedBy) => {
ids.forEach((id) => updateRequirement(id, { versionId: version.id, addedToVersionBy: addedBy }));
}}
onUnlink={(id) => updateRequirement(id, { versionId: undefined, addedToVersionBy: undefined })}
/>
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
(() => {
const pt = activeTab as 'research' | 'product' | 'ui';
const versionReqs = requirements.filter((r) => r.versionId === version.id);
const linkedReqs = versionReqs.map((r) => ({ id: r.id, title: r.title, code: r.code, productOwner: r.productOwner }));
return (
<PlanTab
plans={plans}
versionId={version.id}
versionDeadline={version.expectedReleaseDate ?? undefined}
currentUserName={user?.name ?? ''}
planType={pt}
linkedRequirements={pt !== 'research' ? linkedReqs : undefined}
onCreate={(data) => {
createPlan(data);
if ((pt === 'product') && data.linkedRequirementIds?.length) {
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
}
// 同步版本状态:计划开始时间<=今天,版本进入对应阶段
const today = new Date().toISOString().slice(0, 10);
if (data.startTime <= today && (version.status === 'planned' || version.status === 'developing')) {
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
updateVersion(version.productId, version.id, { status: 'developing', currentStage: stageMap[pt] });
}
}}
onUpdate={(id, data) => {
updatePlan(id, data);
if ((pt === 'product') && data.linkedRequirementIds && data.owner) {
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
}
}}
onComplete={completePlan}
onDelete={deletePlan}
/>
);
})()
) : activeTab === 'tasks' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return (
<DevTaskTab
versionId={version.id}
requirementIds={versionReqs.map((r) => r.id)}
versionDeadline={version.expectedReleaseDate ?? undefined}
/>
);
})()
) : activeTab === 'testcases' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return <TestCaseTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} />;
})()
) : activeTab === 'bugs' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return <BugTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} />;
})()
) : (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">
<span className="text-[13px] text-[var(--ink-muted)]"></span>
</div>
)}
</div>
</div>
);
}
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)]';
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>
);
}