Files
ftb-project-management/apps/web/app/projects/[id]/page.tsx
Script Generator 342fff7f06 refactor: 去掉"联调"阶段,新增"BUG"阶段胶囊
- Stage 类型去掉 integration,新增 bug
- 胶囊条:调研 → 产品设计 → UI设计 → 开发 → 测试 → BUG → 上线
- BUG 阶段进度 = 已关闭Bug / 总Bug
- 全部关闭 = done(绿色),有Bug未关闭 = active(蓝色)
- 版本列表/项目详情的阶段筛选同步去掉联调

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

397 lines
19 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 { 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 } from '@/lib/dev-task';
import { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips';
/* ─── 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, onNavigate }: { version: VersionWithContext; progress: number; onNavigate: (id: string) => void }) {
const [expanded, setExpanded] = useState(false);
// Calculate actual elapsed days from dates instead of old progress data
const totalDays = useMemo(() => {
if (!version.startDate) return 0;
const start = new Date(version.startDate);
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);
return Math.max(0, Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)));
}, [version.startDate, version.releaseDate, version.status]);
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 />
<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 [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, developing: 0, released: 0, totalDays: 0, reqCount: 0, overtimeHours: 0 };
const total = project.versions.length;
const developing = project.versions.filter((v) => v.status === 'developing').length;
const released = project.versions.filter((v) => v.status === 'released').length;
const totalDays = project.versions.reduce((sum, v) => {
if (!v.startDate) return sum;
const start = new Date(v.startDate);
start.setHours(0, 0, 0, 0);
const end = v.status === 'released' && v.releaseDate ? new Date(v.releaseDate) : new Date();
end.setHours(0, 0, 0, 0);
return sum + Math.max(0, Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)));
}, 0);
const reqCount = requirements.filter((r) => r.projectId === projectId).length;
const overtimeHours = Math.round(records.filter((r) => r.projectId === projectId).reduce((sum, r) => sum + r.duration, 0) * 10) / 10;
return { total, developing, released, totalDays, reqCount, overtimeHours };
}, [project, requirements, records, 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-6 gap-4">
<StatCard value={stats.total} label="总版本数" />
<StatCard value={stats.developing} label="进行中" />
<StatCard value={stats.released} label="已发布" />
<StatCard value={stats.totalDays} label="总耗时(天)" />
<StatCard value={stats.reqCount} label="需求数" />
<StatCard value={stats.overtimeHours} label="加班(h)" />
</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} onNavigate={(id) => router.push(`/versions/${id}`)} />)
)}
</div>
</section>
</div>
</div>
</div>
);
}