Files
ftb-project-management/apps/web/app/projects/[id]/page.tsx
Script Generator 6d2f6d8b9f docs+fix: 4份核心文档 + 项目模块版本记录数据全联动
文档:
- architecture.md - 整体架构、心智模型、关键设计原则
- decisions.md - 16条关键决策记录(含为什么)
- workflow.md - 工作流程和协作偏好
- roadmap.md - V1/V2/V3 路线图和已完成清单

修复项目详情版本记录:
1. 状态胶囊数据联动(开发中分支也补传 stageProgress)
2. 日期数据联动:
   - 实际开始 = 取所有阶段最早 actualStartAt/startDate/startedAt
   - 实际截止 = 取所有阶段最晚 completedAt/closedAt
   - 数据完全和版本详情一致

VersionCard 的 versionData useMemo 增加 actualStart/actualEnd 派生字段,
两种状态分支(developing/released)都使用派生值,不再用静态 version.startDate

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

507 lines
24 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, Users, Tag, ChevronDown } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { getProjectDetail, VersionWithContext } from '@/lib/derive';
import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
import { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips';
import type { VersionPlan } from '@/lib/version-plan';
import type { DevTask } from '@/lib/dev-task';
import type { TestCase } from '@/lib/test-case';
import type { Bug } from '@/lib/bug';
/* ─── StatCard ─── */
function StatCard({ value, label }: { value: number | string; label: string }) {
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-2xl font-bold text-[var(--ink)]">{value}</div>
<div className="text-xs text-[var(--ink-muted)] mt-1">{label}</div>
</div>
);
}
/* ─── ProgressBar (for expanded released cards) ─── */
function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number; daysSpent: number }) {
return (
<div className="flex items-center gap-2">
<span className="w-12 text-xs text-[var(--ink-soft)] shrink-0">{ROLE_LABEL[role]}</span>
<div className="flex-1 h-2 rounded-full bg-zinc-100 overflow-hidden">
<div className="h-full rounded-full bg-blue-500 transition-all" style={{ width: `${percent}%` }} />
</div>
<span className="text-xs text-[var(--ink-muted)] w-8 text-right">{percent}%</span>
<span className="text-xs text-[var(--ink-muted)] w-10 text-right">
{daysSpent === 0 ? '-' : `${daysSpent}`}
</span>
</div>
);
}
/* ─── VersionCard ─── */
function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requirements, onNavigate }: {
version: VersionWithContext;
progress: number;
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
requirements: { id: string; versionId?: string }[];
onNavigate: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
// 与版本详情一致:取所有阶段最早的实际开始
const versionData = useMemo(() => {
const vPlans = plans.filter((p) => p.versionId === version.id);
const vReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.id));
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
const vTCs = testCases.filter((c) => c.versionId === version.id);
const vBugs = bugs.filter((b) => b.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.startDate) startDates.push(t.startDate); });
vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
const earliestStart = startDates.length > 0 ? startDates.sort()[0] : version.startDate;
const actualStartDisplay = startDates.length > 0 ? startDates.sort()[0].slice(0, 10) : (version.startDate ?? null);
// 实际截止:取所有阶段最晚完成
const endDates: string[] = [];
vPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
vDevTasks.forEach((t) => { if (t.completedAt) endDates.push(t.completedAt); });
vTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
vBugs.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); });
const actualEndDisplay = endDates.length > 0 ? endDates.sort().reverse()[0].slice(0, 10) : null;
let totalDays = 0;
if (earliestStart) {
const start = new Date(earliestStart);
start.setHours(0, 0, 0, 0);
const end = version.status === 'released' && version.releaseDate ? new Date(version.releaseDate) : new Date();
end.setHours(0, 0, 0, 0);
totalDays = Math.max(0, Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)));
}
return { vPlans, vDevTasks, vTCs, vBugs, totalDays, actualStart: actualStartDisplay, actualEnd: actualEndDisplay };
}, [version, plans, devTasks, testCases, bugs, requirements]);
// 状态胶囊数据 — 与版本详情一致
const stageProgress = useMemo(() => {
const { vPlans, vDevTasks, vTCs, vBugs } = versionData;
const sp: any = {};
const calcGroupProgress = (group: VersionPlan[], type: 'research' | 'product' | 'ui') => {
if (group.length === 0) return 0;
let totalItems = 0;
let doneItems = 0;
for (const p of group) {
if (type === 'research') {
const tasks = p.tasks || [];
const count = Math.max(tasks.length, 1);
totalItems += count;
if (p.status === 'completed') doneItems += count;
else doneItems += tasks.filter((t) => t.status === 'completed').length;
} else {
const linked = p.linkedRequirementIds || [];
const count = Math.max(linked.length, 1);
totalItems += count;
if (p.status === 'completed') doneItems += count;
else {
const completed = p.completedRequirementIds || [];
doneItems += completed.filter((id) => linked.includes(id)).length;
}
}
}
return totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0;
};
const getPlanStatus = (group: VersionPlan[]): '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';
};
const research = vPlans.filter((p) => p.type === 'research');
const product = vPlans.filter((p) => p.type === 'product');
const ui = vPlans.filter((p) => p.type === 'ui');
if (research.length > 0) sp['requirement'] = { percent: calcGroupProgress(research, 'research'), status: getPlanStatus(research) };
if (product.length > 0) sp['product_design'] = { percent: calcGroupProgress(product, 'product'), status: getPlanStatus(product) };
if (ui.length > 0) sp['ui_design'] = { percent: calcGroupProgress(ui, 'ui'), status: getPlanStatus(ui) };
if (vDevTasks.length > 0) {
const devProgress = calcDevTaskProgress(vDevTasks);
const allSubmitted = vDevTasks.every((t) => t.status === 'submitted');
const hasActive = vDevTasks.some((t) => t.status === 'in_progress' || t.status === 'testing');
sp['dev'] = { percent: devProgress, status: allSubmitted ? 'done' : hasActive ? 'active' : 'idle' };
}
if (vTCs.length > 0) {
const executed = vTCs.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
const tp = Math.round((executed / vTCs.length) * 100);
const allPassed = vTCs.every((c) => c.status === 'passed');
const hasRunning = vTCs.some((c) => c.status === 'running');
sp['testing'] = { percent: tp, status: allPassed ? 'done' : (hasRunning || executed > 0) ? 'active' : 'idle' };
}
if (vBugs.length > 0) {
const closedBugs = vBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length;
const bp = Math.round((closedBugs / vBugs.length) * 100);
const allClosed = vBugs.every((b) => b.status === 'closed' || b.status === 'rejected');
sp['bug'] = { percent: bp, status: allClosed ? 'done' : closedBugs > 0 || vBugs.length > 0 ? 'active' : 'idle' };
}
return sp;
}, [versionData]);
const totalDays = versionData.totalDays;
const displayStatus = VERSION_STATUS_LABEL[version.status] ?? '开发中';
const displayBg = VERSION_STATUS_BG[version.status] ?? 'bg-blue-500/10 text-blue-600';
if (version.status === 'planned') {
return (
<div className="rounded-xl border border-dashed border-[var(--line)] px-4 py-3">
<div className="flex items-center gap-2">
<span onClick={() => onNavigate(version.id)} className="text-sm font-medium text-[var(--ink)] cursor-pointer hover:text-[var(--accent)]">{version.name}</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
<span className="text-[11px] text-[var(--ink-muted)]"></span>
</div>
</div>
);
}
if (version.status === 'released') {
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-[var(--bg-hover)] transition-colors"
>
<span onClick={(e) => { e.stopPropagation(); onNavigate(version.id); }} className="text-sm font-medium text-[var(--ink)] cursor-pointer hover:text-[var(--accent)]">{version.name}</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
<span className="flex-1 text-[11px] text-[var(--ink-muted)] flex items-center gap-1">
<Calendar className="h-3 w-3" />
{versionData.actualStart ?? version.startDate ?? '-'}
<span className="mx-1"></span>
{versionData.actualEnd ?? version.releaseDate ?? '-'}
<span className="ml-1"> {totalDays} </span>
</span>
<ChevronDown className={`h-3.5 w-3.5 text-[var(--ink-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
{expanded && (
<div className="px-4 pb-4 pt-2 border-t border-[var(--line-soft)] space-y-3">
<CapsuleStages stageProgress={stageProgress} />
<MemberChips members={version.members ?? []} />
</div>
)}
</div>
);
}
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<span onClick={() => onNavigate(version.id)} className="text-sm font-medium text-[var(--ink)] cursor-pointer hover:text-[var(--accent)]">{version.name}</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
<div className="flex-1 flex items-center gap-2 ml-2">
<div className="flex-1 h-1.5 rounded-full bg-zinc-100 max-w-[160px]">
<div className="h-1.5 rounded-full bg-blue-500 transition-all" style={{ width: `${progress}%` }} />
</div>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{progress}%</span>
</div>
</div>
<div className="mb-3"><CapsuleStages stageProgress={stageProgress} /></div>
<div className="flex items-center justify-between text-[11px] text-[var(--ink-muted)] mb-3 pb-3 border-b border-[var(--line-soft)]">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{versionData.actualStart ?? version.startDate ?? '-'}
<span className="mx-1"></span>
{version.expectedReleaseDate ?? '-'}
{versionData.actualEnd && (
<>
<span className="mx-1">|</span>
{versionData.actualEnd}
</>
)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> {totalDays}
</span>
</div>
<MemberChips members={version.members ?? []} />
</div>
);
}
/* ─── TeamSection (compact, overflow with tooltip) ─── */
function TeamSection({ teamByRole }: { teamByRole: Record<string, Record<string, number>> }) {
const MAX_VISIBLE = 4;
return (
<section>
<div className="flex items-center gap-2 mb-3">
<Users className="h-4 w-4 text-[var(--ink-soft)]" />
<h2 className="text-sm font-semibold text-[var(--ink)]"></h2>
</div>
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3">
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
{ROLES.map((role) => {
const peopleMap = teamByRole[role.key] || {};
const people = Object.entries(peopleMap).sort((a, b) => (b[1] as number) - (a[1] as number));
if (people.length === 0) return null;
const visible = people.slice(0, MAX_VISIBLE);
const hidden = people.slice(MAX_VISIBLE);
return (
<div key={role.key} className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-[var(--ink-muted)]">{role.label}</span>
<div className="flex items-center gap-1">
{visible.map(([name, count]) => (
<span key={name} className="inline-flex items-center h-5 px-1.5 rounded bg-[var(--bg-subtle)] text-[11px] text-[var(--ink-soft)]">
{name}<span className="text-[var(--ink-muted)] ml-0.5">{'×'}{count as number}</span>
</span>
))}
{hidden.length > 0 && (
<span className="relative group">
<span className="inline-flex items-center h-5 px-1.5 rounded bg-[var(--bg-subtle)] text-[11px] text-[var(--ink-muted)] cursor-default">
+{hidden.length}
</span>
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 hidden group-hover:flex flex-col items-center z-10">
<span className="whitespace-nowrap rounded-lg bg-zinc-800 px-2.5 py-1.5 text-[11px] text-white shadow-lg">
{hidden.map(([n, c]) => `${n}(×${c})`).join(', ')}
</span>
<span className="h-1.5 w-1.5 rotate-45 bg-zinc-800 -mt-1" />
</span>
</span>
)}
</div>
</div>
);
})}
</div>
</div>
</section>
);
}
/* ─── Status Filter ─── */
const FILTER_OPTIONS: { key: string; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'requirement', label: '调研' },
{ key: 'product_design', label: '产品设计' },
{ key: 'ui_design', label: 'UI设计' },
{ key: 'dev', label: '开发' },
{ key: 'testing', label: '测试' },
{ key: 'released', label: '已发布' },
{ key: 'planned', label: '规划中' },
];
/* ─── Main Page ─── */
export default function ProjectDetailPage() {
const params = useParams();
const router = useRouter();
const projectId = params.id as string;
const { overview, fetchOverview } = useProductStore();
const { requirements, fetchRequirements } = useRequirementStore();
const { records, fetchRecords } = useOvertimeStore();
const { plans, fetchPlans } = useVersionPlanStore();
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
const { testCases, fetchTestCases } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore();
const [statusFilter, setStatusFilter] = useState<string>('all');
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
const project = useMemo(() => getProjectDetail(overview, projectId), [overview, projectId]);
const sortedVersions = useMemo(() => {
if (!project) return [];
let list = [...project.versions];
if (statusFilter !== 'all') {
if (statusFilter === 'planned') {
list = list.filter((v) => v.status === 'planned');
} else if (statusFilter === 'released') {
list = list.filter((v) => v.status === 'released');
} else {
list = list.filter((v) => v.status === 'developing' && v.currentStage === statusFilter);
}
}
return list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [project, statusFilter]);
// Compute actual overall progress per version
const versionProgressMap = useMemo(() => {
if (!project) return {} as Record<string, number>;
const map: Record<string, number> = {};
for (const v of project.versions) {
const vPlans = plans.filter((p) => p.versionId === v.id);
const vReqs = requirements.filter((r) => r.versionId === v.id);
const vReqIds = new Set(vReqs.map((r) => r.id));
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
const vTestCases = testCases.filter((c) => c.versionId === v.id);
const segments: number[] = [];
const researchPlans = vPlans.filter((p) => p.type === 'research');
if (researchPlans.length > 0) {
const totals = researchPlans.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 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
const productPlans = vPlans.filter((p) => p.type === 'product');
if (productPlans.length > 0) {
const totals = productPlans.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 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
const uiPlans = vPlans.filter((p) => p.type === 'ui');
if (uiPlans.length > 0) {
const totals = uiPlans.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 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
if (vDevTasks.length > 0) {
const totalEstimate = vDevTasks.reduce((sum, t) => sum + t.estimateHours, 0);
let devProgress: number;
if (totalEstimate === 0) {
devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length;
} else {
const weighted = vDevTasks.reduce((sum, t) => sum + t.estimateHours * STATUS_PROGRESS[t.status], 0);
devProgress = weighted / totalEstimate;
}
segments.push(devProgress);
}
if (vTestCases.length > 0) {
const executed = vTestCases.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
segments.push((executed / vTestCases.length) * 100);
}
map[v.id] = segments.length > 0 ? Math.round(segments.reduce((s, x) => s + x, 0) / segments.length) : 0;
}
return map;
}, [project, plans, requirements, devTasks, testCases]);
const stats = useMemo(() => {
if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0 };
const total = project.versions.length;
const released = project.versions.filter((v) => v.status === 'released').length;
const reqCount = requirements.filter((r) => r.projectId === projectId).length;
const versionIds = new Set(project.versions.map((v) => v.id));
const bugCount = bugs.filter((b) => versionIds.has(b.versionId)).length;
return { total, released, reqCount, bugCount };
}, [project, requirements, bugs, projectId]);
const teamByRole = useMemo(() => {
if (!project) return {} as Record<string, Record<string, number>>;
const map: Record<string, Record<string, number>> = {};
ROLES.forEach((r) => (map[r.key] = {}));
project.versions.forEach((v) => {
(v.members ?? []).forEach((m) => {
if (!map[m.role]) map[m.role] = {};
map[m.role][m.name] = (map[m.role][m.name] || 0) + 1;
});
});
return map;
}, [project]);
if (!project) {
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('/projects')} className="text-xs text-[var(--accent)] hover:underline"></button>
</div>
);
}
return (
<div className="flex h-full flex-col">
<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('/projects')} 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)]">{project.name}</span>
</div>
<div className="flex items-center gap-1.5 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-xs text-[var(--ink-soft)]">
<Package className="h-3 w-3" /><span>{project.productName}</span>
</div>
</header>
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
<div className="space-y-5">
<div className="grid grid-cols-4 gap-4">
<StatCard value={stats.total} label="总版本数" />
<StatCard value={stats.released} label="已开发" />
<StatCard value={stats.reqCount} label="需求数" />
<StatCard value={stats.bugCount} label="Bug 总数" />
</div>
<TeamSection teamByRole={teamByRole} />
<section>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-[var(--ink-soft)]" />
<h2 className="text-sm font-semibold text-[var(--ink)]"></h2>
</div>
<div className="flex items-center gap-0.5 rounded-lg bg-[var(--bg-subtle)] p-0.5">
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => setStatusFilter(opt.key)}
className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-colors ${statusFilter === opt.key ? 'bg-[var(--bg-card)] text-[var(--ink)] shadow-sm' : 'text-[var(--ink-muted)] hover:text-[var(--ink-soft)]'}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="space-y-2.5">
{sortedVersions.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--line)] p-6 text-center text-xs text-[var(--ink-muted)]"></div>
) : (
sortedVersions.map((v) => <VersionCard key={v.id} version={v} progress={versionProgressMap[v.id] ?? 0} plans={plans} devTasks={devTasks} testCases={testCases} bugs={bugs} requirements={requirements} onNavigate={(id) => router.push(`/versions/${id}`)} />)
)}
</div>
</section>
</div>
</div>
</div>
);
}