'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, UserPlus, X } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { getVersionDetail } from '@/lib/derive';
import type { Role } from '@/lib/stage';
import { CapsuleStages } from '@/components/version/CapsuleStages';
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health';
import { CHANGE_REASON_LABEL } 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 { useMemberStore } from '@/stores/useMemberStore';
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
import { hasPermission } from '@/lib/permissions';
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
import { formatDateTime } from '@/lib/format';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
function formatOverviewDateTime(value?: string | null): string {
if (!value) return '-';
return value.includes('T') ? formatDateTime(value) : value;
}
const BUG_SEVERITY_SEGMENTS = [
{ key: 'critical', label: '致命', color: 'bg-red-500' },
{ key: 'major', label: '严重', color: 'bg-orange-500' },
{ key: 'minor', label: '一般', color: 'bg-amber-400' },
{ key: 'trivial', label: '轻微', color: 'bg-zinc-400' },
] as const;
const TABS = [
{ key: 'overview', label: '概览', permission: null as string | null },
{ key: 'requirements', label: '关联需求', permission: 'version.req:view' },
{ key: 'research', label: '调研', permission: 'version.research:view' },
{ key: 'product', label: '产品方案', permission: 'version.product_plan:view' },
{ key: 'ui', label: 'UI设计', permission: 'version.ui_plan:view' },
{ key: 'tasks', label: '开发任务', permission: 'version.devtask:view' },
{ key: 'testcases', label: '测试用例', permission: 'version.testcase:view' },
{ key: 'bugs', label: 'BUG', permission: 'version.bug:view' },
];
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, createRequirement } = useRequirementStore();
const { records, fetchRecords } = useOvertimeStore();
const { plans, fetchPlans, createPlan, updatePlan, completePlan, deletePlan } = useVersionPlanStore();
const { tasks: devTasks, fetchTasks: fetchDevTasks, deleteTask: deleteDevTask } = useDevTaskStore();
const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore();
const { bugs, fetchBugs, deleteBug } = useBugStore();
const user = useAuthStore((s) => s.user);
const { departments, members: allMembers, roles } = useMemberStore();
const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]);
const memberCandidates = useMemo(
() => allMembers.map((member) => ({
id: member.id,
name: member.name,
departmentName: departments.find((department) => department.id === member.departmentId)?.name,
})),
[allMembers, departments],
);
const visibleTabs = useMemo(
() => TABS.filter((t) => t.permission === null || hasPermission(currentRole, t.permission)),
[currentRole],
);
const [activeTab, setActiveTab] = useState('overview');
useEffect(() => {
if (visibleTabs.length > 0 && !visibleTabs.find((t) => t.key === activeTab)) {
setActiveTab(visibleTabs[0].key);
}
}, [visibleTabs, activeTab]);
const [showMemberModal, setShowMemberModal] = useState(false);
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]);
if (!version) {
return (
版本不存在
);
}
// 权限校验:只有参与人员可以访问(超管不受限)
const currentUserName = user?.name || '';
const isSuperAdmin = !!currentRole && currentRole.permissions.includes('*');
const isMember = isSuperAdmin || (version.members ?? []).length === 0 || (version.members ?? []).some((m) => m.name === currentUserName);
if (!isMember) {
return (
您不是该版本的参与人员,无权访问
);
}
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('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
// 释放关联需求
requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, { versionId: undefined, addedToVersionBy: undefined }));
// 清理计划任务
plans.filter((p) => p.versionId === version.id).forEach((p) => deletePlan(p.id));
// 清理开发任务
const versionReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.id));
devTasks.filter((t) => versionReqIds.has(t.requirementId)).forEach((t) => deleteDevTask(t.id));
// 清理测试用例和Bug
testCases.filter((c) => c.versionId === version.id).forEach((c) => deleteTestCase(c.id));
bugs.filter((b) => b.versionId === version.id).forEach((b) => deleteBug(b.id));
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) => (
));
};
return (
{/* Header */}
/
{version.projectName && version.projectName !== '未关联' && (
<>
/
{version.projectName}
>
)}
/
{version.name}
{renderActions()}
{/* Tab bar */}
{visibleTabs.map((tab) => (
))}
{/* Content */}
{activeTab === 'overview' ? (
(() => {
const now = new Date();
const versionReqs = requirements.filter((r) => r.versionId === version.id);
const versionReqIds = new Set(versionReqs.map((r) => r.id));
const versionPlans = plans.filter((p) => p.versionId === version.id);
const versionDevTasks = devTasks.filter((t) => versionReqIds.has(t.requirementId));
const versionTCs = testCases.filter((c) => c.versionId === version.id);
const versionBugs = bugs.filter((b) => b.versionId === version.id);
const versionOT = records.filter((r) => r.versionId === version.id);
const stageEffortMetrics = calcStageEffortMetrics({
plans: versionPlans,
devTasks: versionDevTasks,
testCases: versionTCs,
bugs: versionBugs,
now,
});
const effortTotals = calcVersionOverviewEffortTotals({
plans: versionPlans,
devTasks: versionDevTasks,
testCases: versionTCs,
bugs: versionBugs,
overtimeRecords: versionOT,
now,
});
const personalRanking = calcPersonalEffortRanking({
plans: versionPlans,
devTasks: versionDevTasks,
testCases: versionTCs,
bugs: versionBugs,
overtimeRecords: versionOT,
now,
});
const bugRanking = calcBugSeverityRanking(versionBugs);
// 人员加班排名
const personOT: Record
= {};
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 = {};
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;
return (
{/* 1. 风险详情 - 最上方 */}
{riskTags.length > 0 && (
风险详情
健康度 {healthScore} · {HEALTH_LEVEL_LABEL[healthLevel]}
{riskTags.map((tag) => (
{tag.label}
{tag.reason &&
原因:{tag.reason}
}
{tag.suggestion &&
建议:{tag.suggestion}
}
))}
)}
{/* 2. 阶段概览 */}
{(() => {
const researchPlans = versionPlans.filter((p) => p.type === 'research');
const productPlans = versionPlans.filter((p) => p.type === 'product');
const uiPlans = versionPlans.filter((p) => p.type === 'ui');
const calcGroupProgress = (group: typeof versionPlans, type: 'research' | 'product' | 'ui') => {
if (group.length === 0) return 0;
// 分子=已完成子任务数,分母=所有plan的子任务总数
// completed 的 plan:其子任务全算已完成
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: typeof versionPlans): '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';
};
type StageProgressItem = { percent: number; status: 'idle' | 'active' | 'done'; actualHours?: number; aiEstimateHours?: number };
const stageProgress: Partial
> = {};
stageProgress['requirement'] = {
percent: calcGroupProgress(researchPlans, 'research'),
status: getPlanStatus(researchPlans),
...stageEffortMetrics.requirement,
};
stageProgress['product_design'] = {
percent: calcGroupProgress(productPlans, 'product'),
status: getPlanStatus(productPlans),
...stageEffortMetrics.product_design,
};
stageProgress['ui_design'] = {
percent: calcGroupProgress(uiPlans, 'ui'),
status: getPlanStatus(uiPlans),
...stageEffortMetrics.ui_design,
};
// 开发阶段
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
const hasActiveDevTask = versionDevTasks.some((t) => t.status === 'in_progress' || t.status === 'testing');
stageProgress['dev'] = {
percent: versionDevTasks.length > 0 ? calcDevTaskProgress(versionDevTasks) : 0,
status: versionDevTasks.length === 0 ? 'idle' : allSubmitted ? 'done' : hasActiveDevTask ? 'active' : 'idle',
...stageEffortMetrics.dev,
};
// 测试阶段
const executed = versionTCs.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
const testPercent = versionTCs.length > 0 ? Math.round((executed / versionTCs.length) * 100) : 0;
const allPassed = versionTCs.length > 0 && versionTCs.every((c) => c.status === 'passed');
const hasRunning = versionTCs.some((c) => c.status === 'running');
stageProgress['testing'] = {
percent: testPercent,
status: versionTCs.length === 0 ? 'idle' : allPassed ? 'done' : (hasRunning || executed > 0) ? 'active' : 'idle',
...stageEffortMetrics.testing,
};
// BUG 阶段:已关闭Bug / 总Bug
const closedBugs = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length;
const bugPercent = versionBugs.length > 0 ? Math.round((closedBugs / versionBugs.length) * 100) : 0;
const allClosed = versionBugs.length > 0 && versionBugs.every((b) => b.status === 'closed' || b.status === 'rejected');
stageProgress['bug'] = {
percent: bugPercent,
status: versionBugs.length === 0 ? 'idle' : allClosed ? 'done' : 'active',
...stageEffortMetrics.bug,
};
return ;
})()}
{/* 3. 时间与投入概览 + 参与人员 + 相关链接 */}
{(() => {
const startDates: string[] = [];
versionPlans.forEach((p) => {
if (p.actualStartAt) startDates.push(p.actualStartAt);
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= now) startDates.push(p.startTime);
});
versionDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
versionTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
const actualStartIso = startDates.length > 0 ? startDates.sort()[0] : (version.startDate ?? null);
const endDates: string[] = [];
versionPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
versionDevTasks.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); });
versionTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
versionBugs.forEach((b) => {
if (b.closedAt) endDates.push(b.closedAt);
else if (b.resolvedAt) endDates.push(b.resolvedAt);
else if ((b.status === 'closed' || b.status === 'rejected') && b.updatedAt) endDates.push(b.updatedAt);
});
const actualEndIso = endDates.length > 0 ? endDates.sort().reverse()[0] : null;
const isTerminalVersion = version.status === 'released' || version.status === 'closed';
const deadline = version.expectedReleaseDate;
const versionActualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
let overdueDays = 0;
if (deadline && isTerminalVersion && actualEndIso) {
const endDate = new Date(actualEndIso);
const deadlineDate = new Date(deadline);
endDate.setHours(0, 0, 0, 0);
deadlineDate.setHours(0, 0, 0, 0);
overdueDays = Math.floor((endDate.getTime() - deadlineDate.getTime()) / (1000 * 60 * 60 * 24));
}
const metrics = [
{ label: '开始', value: actualStartIso ? formatOverviewDateTime(actualStartIso) : '未开始', icon:
},
{ label: '预计截止', value: deadline ?? '未设置' },
{ label: '实际截止', value: isTerminalVersion ? (actualEndIso ? formatOverviewDateTime(actualEndIso) : '未记录') : '未完成' },
{ label: '实际耗时', value: formatActualDuration(versionActualHours), icon:
},
{ label: '人力总投入', value: formatActualDuration(effortTotals.actualHours), tone: 'accent' as const },
{ label: '加班时长', value: formatActualDuration(effortTotals.overtimeHours), sub: '来自加班记录', tone: 'orange' as const },
];
return (
{metrics.map((item) => (
))}
{overdueDays > 0 && (
实际截止晚于预计截止 {overdueDays} 天
)}
);
})()}
参与人员
{(() => {
const membersList = version.members ?? [];
const roleLabel: Record
= { research: '调研', product: '产品', ui: 'UI', frontend: '前端', backend: '后端', testing: '测试' };
if (membersList.length === 0) return 暂无成员,请点击设置添加;
return (
{membersList.map((m, i) => (
{roleLabel[m.role] ?? m.role}
{m.name}
))}
);
})()}
相关链接
{(() => {
const vPlansCompleted = versionPlans.filter((p) => p.status === 'completed' && p.resultUrl);
const typeLabel: Record
= { research: '调研', product: '产品方案', ui: 'UI设计' };
const grouped = ['research', 'product', 'ui'].map((type) => ({
type,
label: typeLabel[type],
items: vPlansCompleted.filter((p) => p.type === type),
})).filter((g) => g.items.length > 0);
if (grouped.length === 0) {
return 暂无成果链接;
}
return (
{grouped.map((g) => (
))}
);
})()}
{/* 5. 加班时长排名 + 加班原因占比 */}
{/* 参与人员加班排名 */}
加班时长排名
{otRanking.length === 0 ? (
暂无数据
) : (
{otRanking.slice(0, 8).map((item, i) => (
{i + 1}
{item.name}
{item.hours}h
))}
)}
{/* 加班原因占比 - 饼图 */}
加班原因占比
{reasonRanking.length === 0 ? (
暂无数据
) : (
{/* SVG 环形饼图 */}
{/* 图例 */}
{reasonRanking.map(([reasonId, hours], i) => {
const colors = ['#3b82f6', '#f97316', '#8b5cf6', '#10b981', '#ef4444', '#f59e0b', '#6366f1', '#ec4899'];
const percent = Math.round((hours / reasonTotal) * 100);
const reasonName = OVERTIME_REASON_LABEL[reasonId] || reasonId;
return (
{reasonName}
{percent}%
);
})}
)}
{/* 6. 需求变更统计 */}
{(() => {
const changeReqs = requirements.filter((r) => r.versionId === version.id && r.reqType === 'change');
if (changeReqs.length === 0) return null;
// 变更人员排名
const personChange: Record = {};
changeReqs.forEach((r) => { const by = r.changeBy || r.creator; personChange[by] = (personChange[by] || 0) + 1; });
const changePersonRanking = Object.entries(personChange).sort((a, b) => b[1] - a[1]);
// 变更原因占比
const reasonCount: Record = {};
changeReqs.forEach((r) => { if (r.changeReason) reasonCount[r.changeReason] = (reasonCount[r.changeReason] || 0) + 1; });
const changeReasonRanking = Object.entries(reasonCount).sort((a, b) => b[1] - a[1]);
const reasonTotal = changeReqs.length;
const colors = ['#f97316', '#3b82f6', '#8b5cf6', '#10b981'];
return (
变更人员排名
{changePersonRanking.length === 0 ? (
暂无数据
) : (
{changePersonRanking.map(([name, count], i) => (
))}
)}
变更原因占比
{changeReasonRanking.map(([reasonId, count], i) => (
{CHANGE_REASON_LABEL[reasonId as keyof typeof CHANGE_REASON_LABEL] || reasonId}
{Math.round((count / reasonTotal) * 100)}%
))}
);
})()}
{/* 个人耗时排名 */}
{(() => {
const maxHours = personalRanking[0]?.total || 1;
return (
{personalRanking.length === 0 ? (
暂无数据
) : (
{personalRanking.slice(0, 8).map((item, i) => {
const totalWidth = (item.total / maxHours) * 100;
const actualWidth = item.total > 0 ? (item.actualHours / item.total) * 100 : 0;
const overtimeWidth = item.total > 0 ? (item.overtimeHours / item.total) * 100 : 0;
return (
{i + 1}
{item.name}
{formatActualDuration(item.total)}
{item.actualHours > 0 &&
}
{item.overtimeHours > 0 &&
}
实际 {formatActualDuration(item.actualHours)}
加班 {formatActualDuration(item.overtimeHours)}
);
})}
)}
);
})()}
{/* Bug 数排名 */}
{(() => {
const maxBugCount = bugRanking[0]?.total || 1;
return (
Bug 数排名
{BUG_SEVERITY_SEGMENTS.map((segment) => (
{segment.label}
))}
{bugRanking.length === 0 ? (
暂无数据
) : (
{bugRanking.slice(0, 8).map((item, i) => {
const totalWidth = (item.total / maxBugCount) * 100;
return (
{i + 1}
{item.assigneeId}
{item.total} 个
{BUG_SEVERITY_SEGMENTS.map((segment) => {
const count = item[segment.key];
if (count <= 0) return null;
return (
);
})}
{BUG_SEVERITY_SEGMENTS.map((segment) => (
{segment.label} {item[segment.key]}
))}
);
})}
)}
);
})()}
{/* 健康趋势 - 暂时不显示 */}
{/*
健康趋势
*/}
);
})()
) : activeTab === 'requirements' ? (
{
ids.forEach((id) => updateRequirement(id, { versionId: version.id, addedToVersionBy: addedBy }));
}}
onUnlink={(id) => updateRequirement(id, { versionId: undefined, addedToVersionBy: undefined })}
onCreateChange={(data) => {
createRequirement({
...data,
productId: version.productId,
projectId: version.projectId,
versionId: version.id,
sourceType: 'internal',
sourceTarget: '',
platforms: [],
typeId: '',
status: 'adopted',
priority: 'P2',
effort: 'M',
creator: user?.name ?? '',
addedToVersionBy: user?.name ?? '',
reqType: 'change',
} as any);
}}
/>
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
(() => {
const pt = activeTab as 'research' | 'product' | 'ui';
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId);
return (
{
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 (
r.id)}
versionDeadline={version.expectedReleaseDate ?? undefined}
/>
);
})()
) : activeTab === 'testcases' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return r.id)} />;
})()
) : activeTab === 'bugs' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return r.id)} />;
})()
) : (
功能开发中,敬请期待
)}
{/* 参与人员设置弹窗 */}
{showMemberModal && (
{
updateVersion(version.productId, version.id, { members: newMembers });
setShowMemberModal(false);
}}
onClose={() => setShowMemberModal(false)}
/>
)}
);
}
function OverviewMetric({ label, value, icon, sub, tone }: {
label: string;
value: string | number;
icon?: JSX.Element;
sub?: string;
tone?: 'accent' | 'orange';
}) {
const valueColor = tone === 'orange' ? 'text-orange-600' : tone === 'accent' ? 'text-[var(--accent)]' : 'text-[var(--ink)]';
return (
{icon && {icon}}
{label}
{value}
{sub &&
{sub}
}
);
}
function MemberSettingModal({ members, allMembers, onSave, onClose }: {
members: { role: Role; name: string }[];
allMembers: { id: string; name: string; departmentName?: string }[];
onSave: (members: { role: Role; name: string }[]) => void;
onClose: () => void;
}) {
const [list, setList] = useState<{ role: Role; name: string }[]>([...members]);
const [query, setQuery] = useState('');
const [selectedNames, setSelectedNames] = useState>(new Set());
const roleOptions: { value: Role; label: string }[] = [
{ value: 'product', label: '产品' },
{ value: 'ui', label: 'UI' },
{ value: 'frontend', label: '前端' },
{ value: 'backend', label: '后端' },
{ value: 'testing', label: '测试' },
];
const candidates = useMemo(
() => filterVersionMemberCandidates(allMembers, list, query),
[allMembers, list, query],
);
const toggleCandidate = (name: string) => {
setSelectedNames((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const handleSelectAll = () => {
setSelectedNames((prev) => {
const next = new Set(prev);
candidates.forEach((member) => next.add(member.name));
return next;
});
};
const handleAddSelected = () => {
if (selectedNames.size === 0) return;
setList(addVersionMembers(list, selectedNames, allMembers, DEFAULT_VERSION_MEMBER_ROLE));
setSelectedNames(new Set());
setQuery('');
};
const handleRoleChange = (name: string, role: Role) => {
setList(list.map((member) => (member.name === name ? { ...member, role } : member)));
};
const handleRemove = (name: string) => {
setList(list.filter((member) => member.name !== name));
setSelectedNames((prev) => {
const next = new Set(prev);
next.delete(name);
return next;
});
};
return (
e.stopPropagation()}>
设置参与人员
{list.length} 人已加入版本
添加成员
setQuery(e.target.value)}
placeholder="搜索成员"
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] pl-9 pr-3 text-[13px] text-[var(--ink)] outline-none transition-colors placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)]"
/>
可添加 {candidates.length} 人
{candidates.length === 0 ? (
暂无可添加成员
) : (
{candidates.map((member) => {
const selected = selectedNames.has(member.name);
return (
);
})}
)}
{list.length === 0 ? (
暂无成员
) : (
{list.map((member) => (
{member.name}
))}
)}
);
}