Files
ftb-project-management/apps/web/app/projects/[id]/page.tsx
2e595c7e72
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
refactor(data): 收口关系表运行时数据源
- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读
- 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移
- 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-09 14:59:49 +08:00

512 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 { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
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 { calcGroupProgress as calcDevTaskProgress, aggregateDevTaskHours } from '@/lib/dev-task';
import { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips';
import { ProjectMemberPanel } from '@/components/project/ProjectMemberPanel';
import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer';
import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton';
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview';
import { calcScopedVersionProgress } from '@/lib/version-progress';
import { buildVersionDataScopeMap, type VersionDataScope } from '@/lib/version-data-scope';
import { formatActualDuration } from '@/lib/work-hours';
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>
);
}
function HoursStatCard({ estimate, actual }: { estimate: number; actual: number }) {
const overrun = actual > estimate && estimate > 0;
const underrun = actual > 0 && actual < estimate;
const tone = overrun ? 'text-red-600' : underrun ? 'text-emerald-600' : 'text-[var(--ink)]';
const dayStr = (h: number) => {
const d = h / 8;
return Number.isInteger(d) ? String(d) : d.toFixed(1).replace(/\.0$/, '');
};
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-baseline gap-1.5">
<span className={`text-2xl font-bold tabular-nums ${tone}`}>{actual > 0 ? `${actual}h` : '—'}</span>
<span className="text-xs text-[var(--ink-muted)] tabular-nums">/ {estimate}h</span>
</div>
<div className="text-xs text-[var(--ink-muted)] mt-1">
/ {estimate > 0 && <span className="ml-1">{actual > 0 ? `${dayStr(actual)} / ` : ''}{dayStr(estimate)}</span>}
</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, scope, onNavigate }: {
version: VersionWithContext;
progress: number;
scope: VersionDataScope;
onNavigate: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(() => getVersionCardDefaultExpanded(version.status));
useEffect(() => {
setExpanded(getVersionCardDefaultExpanded(version.status));
}, [version.status]);
const versionData = useMemo(() => ({
vPlans: scope.plans,
vDevTasks: scope.devTasks,
vTCs: scope.testCases,
vBugs: scope.bugs,
}), [scope]);
const stageEffortMetrics = useMemo(() => calcStageEffortMetrics({
plans: versionData.vPlans,
devTasks: versionData.vDevTasks,
testCases: versionData.vTCs,
bugs: versionData.vBugs,
}), [versionData]);
const timelineSummary = useMemo(() => buildVersionTimelineSummary({
status: version.status,
startDate: version.startDate,
expectedReleaseDate: version.expectedReleaseDate,
releaseDate: version.releaseDate,
plans: versionData.vPlans,
devTasks: versionData.vDevTasks,
testCases: versionData.vTCs,
bugs: versionData.vBugs,
}), [version, versionData]);
// 状态胶囊数据 — 与版本详情一致
const stageProgress = useMemo(() => {
const { vPlans, vDevTasks, vTCs, vBugs } = versionData;
const sp: Partial<Record<Stage, { percent: number; status: 'idle' | 'active' | 'done' }>> = {};
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 summary = getRequirementCoverageSummary(p);
const count = Math.max(summary.total, 1);
totalItems += count;
if (p.status === 'completed') doneItems += count;
else {
doneItems += summary.completed;
}
}
}
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 mergeStageProgressWithEffort(sp, stageEffortMetrics);
}, [versionData, stageEffortMetrics]);
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 (!getVersionCardDefaultExpanded(version.status)) {
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" />
{formatVersionOverviewDateTime(timelineSummary.actualStartIso)}
<span className="mx-1"></span>
{formatVersionOverviewDateTime(timelineSummary.isTerminalVersion ? timelineSummary.actualEndIso : timelineSummary.expectedReleaseIso)}
<span className="ml-1"> {formatActualDuration(timelineSummary.actualHours)}</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" />
{timelineSummary.actualStartIso ? formatVersionOverviewDateTime(timelineSummary.actualStartIso) : '未开始'}
<span className="mx-1"></span>
{timelineSummary.expectedReleaseIso ? formatVersionOverviewDateTime(timelineSummary.expectedReleaseIso) : '未设置'}
<span className="mx-1">|</span>
{timelineSummary.isTerminalVersion ? (timelineSummary.actualEndIso ? formatVersionOverviewDateTime(timelineSummary.actualEndIso) : '未记录') : '未完成'}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> {formatActualDuration(timelineSummary.actualHours)}
</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');
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
const project = useMemo(() => getProjectDetail(overview, projectId), [overview, projectId]);
useEffect(() => {
if (!project) return;
void fetchRequirements({ productId: project.productId, projectId });
project.versions.forEach((version) => {
void fetchPlans({ versionId: version.id });
void fetchDevTasks({ versionId: version.id });
void fetchTestCases({ versionId: version.id });
void fetchBugs({ versionId: version.id });
});
}, [fetchBugs, fetchDevTasks, fetchPlans, fetchRequirements, fetchTestCases, project, projectId]);
const user = useAuthStore((s) => s.user);
const currentUserName = user?.name || '';
const { roles } = useMemberStore();
const currentPermissions = useMemo(
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
[roles, user?.roleId],
);
const isSuperAdmin = useMemo(() => {
const r = roles.find((x) => x.id === user?.roleId);
return !!r && r.permissions.includes('*');
}, [roles, user?.roleId]);
const sortedVersions = useMemo(() => {
if (!project) return [];
let list = [...project.versions];
// 只显示当前用户参与的版本members为空时所有人可见超管可见全部
if (!isSuperAdmin) {
list = list.filter((v) => {
const ms = v.members ?? [];
if (ms.length === 0) return true;
return ms.some((m) => m.name === currentUserName);
});
}
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, isSuperAdmin, currentUserName]);
// Compute actual overall progress per version
const versionScopeMap = useMemo(
() => project ? buildVersionDataScopeMap({
versionIds: project.versions.map((version) => version.id),
plans,
requirements,
devTasks,
testCases,
bugs,
}) : {},
[project, plans, requirements, devTasks, testCases, bugs],
);
const versionProgressMap = useMemo(
() => Object.fromEntries(
Object.entries(versionScopeMap).map(([id, scope]) => [
id,
calcScopedVersionProgress(scope.plans, scope.devTasks, scope.testCases),
]),
),
[versionScopeMap],
);
const stats = useMemo(() => {
if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0, estimateHours: 0, actualHours: 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 versionScopes = Object.values(versionScopeMap);
const bugCount = versionScopes.reduce((sum, scope) => sum + scope.bugs.length, 0);
const projectDevTasks = versionScopes.flatMap((scope) => scope.devTasks);
const { estimate, actual } = aggregateDevTaskHours(projectDevTasks);
return { total, released, reqCount, bugCount, estimateHours: estimate, actualHours: actual };
}, [project, requirements, projectId, versionScopeMap]);
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-2">
<AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} />
<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>
</div>
</header>
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
<div className="space-y-5">
<div className="grid grid-cols-5 gap-4">
<StatCard value={stats.total} label="总版本数" />
<StatCard value={stats.released} label="已开发" />
<StatCard value={stats.reqCount} label="需求数" />
<StatCard value={stats.bugCount} label="Bug 总数" />
<HoursStatCard estimate={stats.estimateHours} actual={stats.actualHours} />
</div>
<TeamSection teamByRole={teamByRole} />
<ProjectMemberPanel projectId={projectId} />
<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} scope={versionScopeMap[v.id]} onNavigate={(id) => router.push(`/versions/${id}`)} />)
)}
</div>
</section>
</div>
</div>
<AnalysisContextDrawer
open={showAnalysisDrawer}
title={`项目智能分析 · ${project.name}`}
context={{ surface: 'project_detail', projectId: project.id }}
permissions={currentPermissions}
onClose={() => setShowAnalysisDrawer(false)}
/>
</div>
);
}