Files
ftb-project-management/apps/web/app/versions/[id]/page.tsx
Script Generator 2acc9aeaa9 feat: 版本详情完整功能 + 数据串联 + 登录模块
- 版本详情:关联需求Tab(从需求池添加已采纳需求/移除释放)
- 版本详情:调研/产品方案/UI设计Tab(计划CRUD、完成提交成果、耗时统计)
- 版本详情:超期校验(结束日期超版本截止需填写原因)
- 版本详情:概览统计卡片(加班时长/排名/原因占比)
- 数据串联:产品→项目→版本→需求→加班全链路贯通
- 版本管理:规划中版本可删除,删除释放关联需求
- 数据清理:仅保留翻台宝/值班,需求池10条值班待评审需求
- 修复:列表页overflow裁剪菜单问题(4个页面统一修复)
- 新增:登录模块、AuthGuard、VersionPlan store

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 17:07:36 +08:00

394 lines
22 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 { VERSION_STATUS_LABEL, VERSION_STATUS_BG } 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 { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useAuthStore } from '@/stores/useAuthStore';
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 user = useAuthStore((s) => s.user);
const [activeTab, setActiveTab] = useState('overview');
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
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('确认删除该版本?关联的需求会回到需求池。')) {
// 释放关联需求
requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, { versionId: undefined, addedToVersionBy: undefined }));
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>
<span className="ml-2 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;
// Mock 数据占位
const mockTaskCount = Math.max(versionReqs.length * 2, 3);
const mockBugTotal = Math.max(Math.floor(versionReqs.length * 1.5), 2);
const mockBugOpen = Math.max(Math.floor(mockBugTotal * 0.3), 1);
const mockTaskPending = Math.max(Math.floor(mockTaskCount * 0.4), 1);
return (
<div className="space-y-4">
{/* Tag row */}
<div className="flex flex-wrap items-center gap-2">
{version.priority && (
<span className={`text-[11px] font-semibold px-2 py-0.5 rounded-full ${PRIORITY_STYLE[version.priority] || PRIORITY_STYLE.P3}`}>
{version.priority}
</span>
)}
<span className={`text-[11px] px-2 py-0.5 rounded-full ${VERSION_STATUS_BG[version.status]}`}>
{VERSION_STATUS_LABEL[version.status]}
</span>
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold tabular-nums px-2 py-0.5 rounded-full ${healthLevel === 'critical' ? 'bg-red-50' : healthLevel === 'risk' ? 'bg-orange-50' : healthLevel === 'attention' ? 'bg-amber-50' : 'bg-emerald-50'} ${HEALTH_LEVEL_COLOR[healthLevel]}`}>
<span className={`h-1.5 w-1.5 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
{healthScore} {HEALTH_LEVEL_LABEL[healthLevel]}
</span>
{riskTags.map((tag) => (
<span key={tag.key} className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
))}
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2.5 py-0.5 text-[11px] text-[var(--ink-soft)]">
<Package className="h-3 w-3" />{version.productName} / {version.projectName}
</span>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-6 gap-3">
<StatCard label="加班时长" value={`${totalOTHours}h`} accent />
<StatCard label="关联需求" value={versionReqs.length} />
<StatCard label="待开发任务" value={mockTaskPending} />
<StatCard label="BUG总数" value={mockBugTotal} />
<StatCard label="未解决BUG" value={mockBugOpen} warn />
<StatCard label="参与人员" value={version.members?.length ?? 0} />
</div>
{/* 风险详情(胶囊条上方) */}
{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>
)}
{/* Capsule stages */}
<CapsuleStages currentStage={version.currentStage} progress={version.progress} />
{/* 双栏:加班排名 + 原因占比 */}
<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>
{/* 双栏:日期信息 + 相关链接 */}
<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>
{version.members && version.members.length > 0 ? (
<MemberChips members={version.members} />
) : (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
)}
</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>
{/* 健康趋势(最底部) */}
<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 }));
}
}}
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}
/>
);
})()
) : (
<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>
);
}