'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 = { 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 (

版本不存在

); } 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) => ( )); }; return (
{/* Header */}
/ {version.projectName && version.projectName !== '未关联' && ( <> / {version.projectName} )} / {version.name}
{renderActions()}
{/* Tab bar */}
{TABS.map((tab) => ( ))}
{/* Content */}
{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 = {}; 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 = {}; 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 (
{/* Tag row */}
{version.priority && ( {version.priority} )} {getVersionDisplayStatus(version.status, version.currentStage)} {EXECUTION_STATUS_LABEL[executionStatus]} {healthScore} {HEALTH_LEVEL_LABEL[healthLevel]} {riskTags.map((tag) => ( {tag.label} ))} {version.productName} / {version.projectName}
{/* 统计卡片 */}
p.versionId === version.id).length} /> 0} />
{/* 风险详情(胶囊条上方) */} {riskTags.length > 0 && (
风险详情 健康度 {healthScore} · {HEALTH_LEVEL_LABEL[healthLevel]}
{riskTags.map((tag) => (
{tag.label}
{tag.reason &&
原因:{tag.reason}
} {tag.suggestion &&
建议:{tag.suggestion}
}
))}
)} {/* Capsule stages */} {(() => { 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 today = new Date().toISOString().slice(0, 10); const calcDays = (group: typeof vPlans) => { if (group.length === 0) return 0; const starts = group.map((p) => p.startTime).sort(); const startDate = starts[0]; if (startDate > today) return 0; const diff = Math.ceil((new Date(today).getTime() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24)); return Math.max(1, diff); // 当天开始至少算1天 }; const stageProgress: Record = {}; if (researchPlans.length > 0) stageProgress['requirement'] = { percent: calcGroupProgress(researchPlans, 'research'), daysSpent: calcDays(researchPlans) }; if (productPlans.length > 0) stageProgress['product_design'] = { percent: calcGroupProgress(productPlans, 'product'), daysSpent: calcDays(productPlans) }; if (uiPlans.length > 0) stageProgress['ui_design'] = { percent: calcGroupProgress(uiPlans, 'ui'), daysSpent: calcDays(uiPlans) }; // 开发阶段进度由 DevTask 驱动 if (versionDevTasks.length > 0) { const devProgress = calcDevTaskProgress(versionDevTasks); const devStart = versionDevTasks.reduce((min, t) => t.startDate && t.startDate < min ? t.startDate : min, today); const devDays = devStart <= today ? Math.max(1, Math.ceil((new Date(today).getTime() - new Date(devStart).getTime()) / (1000 * 60 * 60 * 24))) : 0; stageProgress['dev'] = { percent: devProgress, daysSpent: devDays }; } // 测试阶段进度由 TestCase 完成率驱动 const versionTestCases = testCases.filter((c) => c.versionId === version.id); if (versionTestCases.length > 0) { const executed = versionTestCases.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length; const testPercent = Math.round((executed / versionTestCases.length) * 100); stageProgress['testing'] = { percent: testPercent, daysSpent: 0 }; } return ; })()} {/* 双栏:加班排名 + 原因占比 */}
{/* 参与人员加班排名 */}
加班时长排名
{otRanking.length === 0 ? ( 暂无数据 ) : (
{otRanking.slice(0, 8).map((item, i) => (
{i + 1} {item.name} {item.hours}h
))}
)}
{/* 加班原因占比 */}
加班原因占比
{reasonRanking.length === 0 ? ( 暂无数据 ) : (
{reasonRanking.map(([reasonId, hours]) => { const percent = Math.round((hours / reasonTotal) * 100); const reasonName = OVERTIME_REASON_LABEL[reasonId] || reasonId; return (
{reasonName} {percent}%
); })}
)}
{/* 阶段耗时 + 个人耗时排名 */} {(() => { 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(); 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 (
{/* 阶段日历耗时 */}
阶段耗时(项目维度)
{stages.map((s) => (
{s.label} {s.days > 0 ? `${s.days}天` : '-'}
{s.start && {s.start.slice(5)}{s.end ? ` → ${s.end.slice(5)}` : ' →'}}
))}
总人天投入 {totalHours}h({Math.round(totalHours / 8)}人天)
{/* 个人耗时排名 */}
个人耗时排名
{personalRanking.length === 0 ? ( 暂无数据 ) : (
{personalRanking.slice(0, 8).map((item, i) => (
{i + 1} {item.name} {item.total}h
{item.research > 0 &&
} {item.product > 0 &&
} {item.ui > 0 &&
} {item.dev > 0 &&
} {item.test > 0 &&
}
))}
调研 产品 UI 开发 测试
)}
); })()} {/* 双栏:日期信息 + 相关链接 */}
{version.startDate ?? '未设置'} {version.expectedReleaseDate ?? '未设置'}
已耗时 {elapsedDays}
参与人员
{(() => { 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 = { research: '调研', product: '产品', ui: 'UI' }; // 按 role+name 去重(同阶段同一人只显示一次) const seen = new Set(); 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 暂无成员; return (
{allMembers.map((m, i) => ( {roleLabel[m.role] ?? m.role} {m.name} ))}
); })()}
相关链接
} label="调研报告" url={version.links?.research} /> } label="原型地址" url={version.links?.prototype} /> } label="UI设计稿" url={version.links?.ui} />
{/* 健康趋势(最底部) */}
健康趋势
); })() ) : activeTab === 'requirements' ? ( { 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 ( { 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 ( r.id)} versionDeadline={version.expectedReleaseDate ?? undefined} /> ); })() ) : activeTab === 'testcases' ? ( (() => { const versionReqs = requirements.filter((r) => r.versionId === version.id); return r.id)} />; })() ) : activeTab === 'bugs' ? ( (() => { const versionReqs = requirements.filter((r) => r.versionId === version.id); return r.id)} />; })() ) : (
功能开发中,敬请期待
)}
); } 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 (
{value}
{label}
); } function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string; url?: string }) { return (
{icon} {url ? ( {label} ) : ( {label} · 未设置 )}
); }