Files
ftb-project-management/apps/web/app/projects/[id]/page.tsx
Script Generator 4b05e09dab fix: 项目详情顶部卡片 + 版本记录耗时和状态胶囊与版本详情联动
1. 顶部卡片从 6 个改为 4 个:
   - 总版本数、已开发、需求数、Bug 总数
   - 去掉重复的进行中、总耗时(天)、加班(h)
   - bugCount 从 useBugStore 实时统计

2. 版本记录里的耗时与版本详情联动:
   - 不再用 version.startDate 简单计算
   - 改用版本详情同样的算法:取所有阶段最早实际开始
     (plans.actualStartAt / devTasks.startDate / testCases.startedAt)
   - 数据完全一致

3. 版本记录里的状态胶囊与版本详情联动:
   - 之前 <CapsuleStages /> 不传任何数据,永远空胶囊
   - 现在传入 stageProgress (与版本详情同样算法)
   - 调研/产品/UI/开发/测试/BUG 各阶段进度一致

VersionCard 接收 plans/devTasks/testCases/bugs/requirements
useMemo 派生 versionData 和 stageProgress

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

488 lines
23 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;
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 };
}, [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" />
{version.startDate ?? '-'} {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 /></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" />
{version.startDate ?? '-'} {version.expectedReleaseDate ?? '-'}
</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>
);
}