核心改动: - TestCase 主归属 versionId,requirementId 改为可选语义标签 - Bug 新增 versionId(直接挂版本)+ requirementId(可选追溯) - 新增 calcVersionExecutionStatus() 版本执行态推导函数 规则:DevTask全部提测→已提测,TestCase全部通过+Bug全关→可发布 - 概览页展示推导出的执行态标签 - BugTab 改为直接 versionId 查询(不再走 testCase→req 链路) - 统计卡片"参与人员"替换为"未关闭Bug" 心智模型: 版本(执行线)→ DevTask/TestCase/Bug 需求(语义线)→ 解释为什么做,不驱动流程 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
551 lines
32 KiB
TypeScript
551 lines
32 KiB
TypeScript
'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 { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG, getVersionDisplayStatus, calcVersionExecutionStatus, EXECUTION_STATUS_LABEL, EXECUTION_STATUS_COLOR } 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 { DevTaskTab } from '@/components/dev-task/DevTaskTab';
|
||
import { TestCaseTab } from '@/components/test-case/TestCaseTab';
|
||
import { BugTab } from '@/components/bug/BugTab';
|
||
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 { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
|
||
|
||
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 { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
||
const { testCases, fetchTestCases } = useTestCaseStore();
|
||
const { bugs, fetchBugs } = useBugStore();
|
||
const user = useAuthStore((s) => s.user);
|
||
const [activeTab, setActiveTab] = useState('overview');
|
||
|
||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||
|
||
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
||
|
||
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
|
||
useEffect(() => {
|
||
if (!version || !plans.length) return;
|
||
if (version.status !== 'planned' && version.status !== 'developing') return;
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
|
||
const stageOrder: string[] = ['requirement', 'product_design', 'ui_design'];
|
||
const versionPlans = plans.filter((p) => p.versionId === versionId && p.startTime <= today);
|
||
if (versionPlans.length === 0) return;
|
||
let targetStage = '';
|
||
for (const p of versionPlans) {
|
||
const s = stageMap[p.type];
|
||
if (!targetStage || stageOrder.indexOf(s) > stageOrder.indexOf(targetStage)) {
|
||
targetStage = s;
|
||
}
|
||
}
|
||
if (version.status === 'planned' || (version.currentStage && stageOrder.indexOf(targetStage) > stageOrder.indexOf(version.currentStage))) {
|
||
updateVersion(version.productId, version.id, { status: 'developing', currentStage: targetStage as any });
|
||
}
|
||
}, [plans, version, versionId, updateVersion]);
|
||
|
||
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;
|
||
|
||
// DevTask 真实统计
|
||
const versionDevTasks = devTasks.filter((t) => versionReqs.some((r) => r.id === t.requirementId));
|
||
const devTaskTodo = versionDevTasks.filter((t) => t.status === 'todo').length;
|
||
const devTaskInProgress = versionDevTasks.filter((t) => t.status === 'in_progress').length;
|
||
const devTaskBlocked = versionDevTasks.filter((t) => t.isBlocked).length;
|
||
const versionBugs = bugs.filter((b) => b.versionId === version.id);
|
||
const bugOpenCount = versionBugs.filter((b) => b.status === 'open' || b.status === 'fixing').length;
|
||
|
||
// 版本执行态推导
|
||
const versionTCs = testCases.filter((c) => c.versionId === version.id);
|
||
const executionStatus = calcVersionExecutionStatus({
|
||
manualStatus: version.status,
|
||
devTasks: versionDevTasks,
|
||
testCases: versionTCs,
|
||
bugs: versionBugs,
|
||
});
|
||
|
||
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]}`}>
|
||
{getVersionDisplayStatus(version.status, version.currentStage)}
|
||
</span>
|
||
<span className={`text-[11px] font-medium px-2 py-0.5 rounded-full ${EXECUTION_STATUS_COLOR[executionStatus]}`}>
|
||
{EXECUTION_STATUS_LABEL[executionStatus]}
|
||
</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={devTaskTodo} />
|
||
<StatCard label="开发中" value={devTaskInProgress} accent />
|
||
<StatCard label="阻塞中" value={devTaskBlocked} warn={devTaskBlocked > 0} />
|
||
<StatCard label="未关闭Bug" value={bugOpenCount} warn={bugOpenCount > 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 */}
|
||
{(() => {
|
||
const vPlans = plans.filter((p) => p.versionId === version.id);
|
||
const researchPlans = vPlans.filter((p) => p.type === 'research');
|
||
const productPlans = vPlans.filter((p) => p.type === 'product');
|
||
const uiPlans = vPlans.filter((p) => p.type === 'ui');
|
||
|
||
const calcGroupProgress = (group: typeof vPlans, type: 'research' | 'product' | 'ui') => {
|
||
if (group.length === 0) return 0;
|
||
if (type === 'research') {
|
||
const totals = group.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 });
|
||
return totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0;
|
||
}
|
||
const totals = group.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 });
|
||
return totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0;
|
||
};
|
||
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const calcDays = (group: typeof vPlans) => {
|
||
if (group.length === 0) return 0;
|
||
const starts = group.map((p) => p.startTime).sort();
|
||
const startDate = starts[0];
|
||
if (startDate > today) return 0;
|
||
const diff = Math.ceil((new Date(today).getTime() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24));
|
||
return Math.max(1, diff); // 当天开始至少算1天
|
||
};
|
||
|
||
const stageProgress: Record<string, { percent: number; daysSpent: number }> = {};
|
||
if (researchPlans.length > 0) stageProgress['requirement'] = { percent: calcGroupProgress(researchPlans, 'research'), daysSpent: calcDays(researchPlans) };
|
||
if (productPlans.length > 0) stageProgress['product_design'] = { percent: calcGroupProgress(productPlans, 'product'), daysSpent: calcDays(productPlans) };
|
||
if (uiPlans.length > 0) stageProgress['ui_design'] = { percent: calcGroupProgress(uiPlans, 'ui'), daysSpent: calcDays(uiPlans) };
|
||
|
||
// 开发阶段进度由 DevTask 驱动
|
||
if (versionDevTasks.length > 0) {
|
||
const devProgress = calcDevTaskProgress(versionDevTasks);
|
||
const devStart = versionDevTasks.reduce((min, t) => t.startDate && t.startDate < min ? t.startDate : min, today);
|
||
const devDays = devStart <= today ? Math.max(1, Math.ceil((new Date(today).getTime() - new Date(devStart).getTime()) / (1000 * 60 * 60 * 24))) : 0;
|
||
stageProgress['dev'] = { percent: devProgress, daysSpent: devDays };
|
||
}
|
||
|
||
// 测试阶段进度由 TestCase 完成率驱动
|
||
const versionTestCases = testCases.filter((c) => c.versionId === version.id);
|
||
if (versionTestCases.length > 0) {
|
||
const executed = versionTestCases.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
|
||
const testPercent = Math.round((executed / versionTestCases.length) * 100);
|
||
stageProgress['testing'] = { percent: testPercent, daysSpent: 0 };
|
||
}
|
||
|
||
return <CapsuleStages currentStage={version.currentStage} progress={version.progress} stageProgress={stageProgress} />;
|
||
})()}
|
||
|
||
{/* 双栏:加班排名 + 原因占比 */}
|
||
<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>
|
||
{(() => {
|
||
const planMembers = plans
|
||
.filter((p) => p.versionId === version.id)
|
||
.map((p) => ({ role: p.type === 'research' ? 'research' as const : p.type === 'product' ? 'product' as const : 'ui' as const, name: p.owner }));
|
||
const roleLabel: Record<string, string> = { research: '调研', product: '产品', ui: 'UI' };
|
||
// 按 role+name 去重(同阶段同一人只显示一次)
|
||
const seen = new Set<string>();
|
||
const dedupPlanMembers = planMembers.filter((m) => {
|
||
const key = `${m.role}:${m.name}`;
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
const existingKeys = new Set((version.members ?? []).map((m) => `${m.role}:${m.name}`));
|
||
const extraMembers = dedupPlanMembers.filter((m) => !existingKeys.has(`${m.role}:${m.name}`));
|
||
const allMembers = [...(version.members ?? []), ...extraMembers];
|
||
if (allMembers.length === 0) return <span className="text-[12px] text-[var(--ink-muted)]">暂无成员</span>;
|
||
return (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{allMembers.map((m, i) => (
|
||
<span key={`${m.name}-${i}`} className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-[11px] text-[var(--ink-soft)]">
|
||
<span className="text-[var(--ink-muted)]">{roleLabel[m.role] ?? m.role}</span>
|
||
<span className="font-medium text-[var(--ink)]">{m.name}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
})()}
|
||
</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 }));
|
||
}
|
||
// 同步版本状态:计划开始时间<=今天,版本进入对应阶段
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
if (data.startTime <= today && (version.status === 'planned' || version.status === 'developing')) {
|
||
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
|
||
updateVersion(version.productId, version.id, { status: 'developing', currentStage: stageMap[pt] });
|
||
}
|
||
}}
|
||
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}
|
||
/>
|
||
);
|
||
})()
|
||
) : activeTab === 'tasks' ? (
|
||
(() => {
|
||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
||
return (
|
||
<DevTaskTab
|
||
versionId={version.id}
|
||
requirementIds={versionReqs.map((r) => r.id)}
|
||
versionDeadline={version.expectedReleaseDate ?? undefined}
|
||
/>
|
||
);
|
||
})()
|
||
) : activeTab === 'testcases' ? (
|
||
(() => {
|
||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
||
return <TestCaseTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} />;
|
||
})()
|
||
) : activeTab === 'bugs' ? (
|
||
(() => {
|
||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
||
return <BugTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} />;
|
||
})()
|
||
) : (
|
||
<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>
|
||
);
|
||
}
|