'use client'; import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Pencil, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { getVersionDetail, type Priority, type VersionWithContext } from '@/lib/derive'; import type { Role } from '@/lib/stage'; import { CapsuleStages } from '@/components/version/CapsuleStages'; import { FilterSelect } from '@/components/FilterSelect'; 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 { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer'; import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton'; 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 { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task'; import { hasPermission } from '@/lib/permissions'; import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours'; import { formatDateTime, formatLocalDate } from '@/lib/format'; import { getVersionLinkedRequirementCandidates } from '@/lib/requirement-selector'; import { getRequirementVersionSelectionPatch, getRequirementVersionUnlinkPatch } from '@/lib/requirement-version-link'; import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview'; import { addVersionMembers, canWriteVersionWork, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members'; import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation'; import { getRequirementCoverageSummary } from '@/lib/version-plan'; import { buildVersionDataScope, selectVersionDataScope, type VersionDataScope } from '@/lib/version-data-scope'; import { calcScopedVersionProgress } from '@/lib/version-progress'; import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release'; import { isVersionReadonly } from '@/lib/version-status'; import { loadV22VersionDetailData } from '@/lib/v22-api'; import { VERSION_DEVELOPMENT_TYPE_OPTIONS, buildVersionName, canSubmitVersionEditForm, getEditableVersionNumber, getRecommendedExpectedReleaseDate, getRecommendedWorkDays, getVersionDevelopmentTypeOption, type VersionDevelopmentType, } from '@/lib/version-form'; 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 RECOMMENDATION_ROLE_LABEL: Record = { frontend: '前端', backend: '后端', testing: '测试', }; const RECOMMENDATION_CONFIDENCE_LABEL = { high: '高置信', medium: '中置信', low: '低置信', } 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' }, ]; const PLAN_MANAGE_PERMISSION = { research: 'version.research:manage', product: 'version.product_plan:manage', ui: 'version.ui_plan:manage', } as const; export default function VersionDetailPage() { const params = useParams(); const router = useRouter(); const versionId = params.id as string; const { overview, fetchOverview, updateVersion, deleteVersion } = useProductStore(); const { requirements, loaded: requirementsLoaded, fetchRequirements, updateRequirement, createRequirement, } = useRequirementStore(); const { records, fetchRecords } = useOvertimeStore(); const { plans, loaded: plansLoaded, fetchPlans, createPlan, updatePlan, completePlan, deletePlan, } = useVersionPlanStore(); const { tasks: devTasks, loaded: devTasksLoaded, fetchTasks: fetchDevTasks, deleteTask: deleteDevTask, } = useDevTaskStore(); const { testCases, loaded: testCasesLoaded, fetchTestCases, deleteTestCase, } = useTestCaseStore(); const { bugs, loaded: bugsLoaded, fetchBugs, deleteBug, } = useBugStore(); const user = useAuthStore((s) => s.user); const { departments, members: allMembers, roles, fetchMembers } = useMemberStore(); const { categories: taskCategories, fetchCategories } = useTaskCategoryStore(); const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]); const currentPermissions = currentRole?.permissions ?? []; const memberCandidates = useMemo( () => allMembers.map((member) => ({ id: member.id, name: member.name, username: member.username, 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); const [showRecommendModal, setShowRecommendModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showReleaseModal, setShowReleaseModal] = useState(false); const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false); const [recommendationDataReady, setRecommendationDataReady] = useState(false); const [v22Scope, setV22Scope] = useState(null); const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]); const fetchScopedRequirements = useCallback((force = false) => { if (!version) return Promise.resolve(); if (force) return fetchRequirements({ productId: version.productId, versionId, force: true }); return fetchRequirements({ productId: version.productId, versionId }); }, [fetchRequirements, version, versionId]); useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { let active = true; setV22Scope(null); loadV22VersionDetailData(versionId) .then((data) => { if (active) setV22Scope(data.scope); }) .catch(() => { if (active) setV22Scope(null); }); return () => { active = false; }; }, [versionId]); useEffect(() => { if (activeTab === 'overview') { fetchRecords(); return; } if (!version) return; if (activeTab === 'requirements') { fetchScopedRequirements(); fetchDevTasks({ versionId }); return; } if (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') { fetchScopedRequirements(); fetchPlans({ versionId }); return; } if (activeTab === 'tasks') { fetchScopedRequirements(); fetchDevTasks({ versionId }); return; } if (activeTab === 'testcases') { fetchScopedRequirements(); fetchDevTasks({ versionId }); fetchTestCases({ versionId }); fetchBugs({ versionId }); return; } if (activeTab === 'bugs') { fetchScopedRequirements(); fetchTestCases({ versionId }); fetchBugs({ versionId }); } }, [activeTab, fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchScopedRequirements, fetchTestCases, version, versionId]); useEffect(() => { if (!showRecommendModal) return; fetchRecords(); fetchScopedRequirements(true); fetchPlans({ versionId }); fetchDevTasks({ versionId }); fetchTestCases({ versionId }); fetchBugs({ versionId }); }, [fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchScopedRequirements, fetchTestCases, showRecommendModal, versionId]); useEffect(() => { let active = true; setRecommendationDataReady(false); Promise.all([Promise.resolve(fetchMembers()), Promise.resolve(fetchCategories())]).finally(() => { if (active) setRecommendationDataReady(true); }); return () => { active = false; }; }, [fetchMembers, fetchCategories]); const storeVersionScope = useMemo( () => version ? buildVersionDataScope({ versionId: version.id, requirements, plans, devTasks, testCases, bugs, overtimeRecords: records, }) : null, [version, requirements, plans, devTasks, testCases, bugs, records], ); const versionWriteStoresReady = requirementsLoaded && plansLoaded && devTasksLoaded && testCasesLoaded && bugsLoaded; const requirementWriteReady = requirementsLoaded && devTasksLoaded; const planWriteReady = requirementsLoaded && plansLoaded; const devTaskWriteReady = requirementsLoaded && devTasksLoaded; const testCaseWriteReady = requirementsLoaded && devTasksLoaded && testCasesLoaded && bugsLoaded; const bugWriteReady = requirementsLoaded && testCasesLoaded && bugsLoaded; const loadAllVersionStores = () => { if (!version) return; fetchRecords(); fetchRequirements({ productId: version.productId, versionId, force: true }); fetchPlans({ versionId }); fetchDevTasks({ versionId }); fetchTestCases({ versionId }); fetchBugs({ versionId }); }; const versionScope = useMemo( () => selectVersionDataScope({ storeScope: storeVersionScope, v22Scope, }), [storeVersionScope, v22Scope], ); const releaseProgress = versionScope ? calcScopedVersionProgress(versionScope.plans, versionScope.devTasks, versionScope.testCases) : 0; const versionReadonly = version ? isVersionReadonly(version.status) : false; // 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段 useEffect(() => { if (!version || !versionScope || !versionScope.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 = versionScope.plans.filter((p) => 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 }); } }, [versionScope, version, 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 versionMembers = version.members ?? []; const canWriteVersionPermission = (permission: string) => canWriteVersionWork({ role: currentRole, permission, currentUserName, versionMembers, readOnly: versionReadonly, }); const canManageRequirements = canWriteVersionPermission('version.req:manage'); const canManageDevTasks = canWriteVersionPermission('version.devtask:manage'); const canManageTestCases = canWriteVersionPermission('version.testcase:manage'); const canManageBugs = ['version.bug:create', 'version.bug:edit', 'version.bug:delete'] .some((permission) => canWriteVersionPermission(permission)); const scopedVersionData = versionScope!; const displayRequirements = scopedVersionData.requirements; const displayPlans = scopedVersionData.plans; const displayDevTasks = scopedVersionData.devTasks; const displayTestCases = scopedVersionData.testCases; const displayBugs = scopedVersionData.bugs; const displayRequirementIds = scopedVersionData.requirementIds; const memberRecommendationGroups = showRecommendModal && recommendationDataReady ? (() => { const currentSystemParticipation = new Map(); overview.forEach((product) => { product.versions.forEach((item) => { const uniqueNames = new Set((item.members ?? []).map((member) => member.name)); uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1)); }); }); return recommendVersionMembers({ candidates: memberCandidates, currentMembers: version.members ?? [], devTasks, testCases, bugs, overtimeRecords: records, versionDeadline: version.expectedReleaseDate, scopeRequirements: scopedVersionData.requirements, scopeDevTasks: scopedVersionData.devTasks, scopeTestCases: scopedVersionData.testCases, taskCategories, historicalStats: memberCandidates.flatMap((member) => { const projectParticipationCount = currentSystemParticipation.get(member.name); return typeof projectParticipationCount === 'number' ? [{ name: member.name, projectParticipationCount }] : []; }), }); })() : []; const renderActions = () => { const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release'; icon?: JSX.Element }[] = []; if (versionReadonly) return null; if (version.status !== 'released' && version.status !== 'closed') { buttons.push({ label: '发版', action: () => setShowReleaseModal(true), tone: 'release' }); } buttons.push({ label: '编辑', action: () => setShowEditModal(true), icon: }); if (version.status === 'planned') { buttons.push({ label: '删除', action: () => { if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) { // 释放关联需求 if (!versionWriteStoresReady) { loadAllVersionStores(); alert('版本关联数据正在加载,请稍后再删除。'); return; } scopedVersionData.requirements.forEach((r) => updateRequirement(r.id, { productId: r.productId, ...getRequirementVersionUnlinkPatch(r), })); // 清理计划任务 scopedVersionData.plans.forEach((p) => deletePlan(p.id)); // 清理开发任务 scopedVersionData.devTasks.forEach((t) => deleteDevTask(t.id)); // 清理测试用例和Bug scopedVersionData.testCases.forEach((c) => deleteTestCase(c.id)); scopedVersionData.bugs.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}
setShowAnalysisDrawer(true)} /> {renderActions()}
{/* Tab bar */}
{visibleTabs.map((tab) => ( ))}
{/* Content */}
{activeTab === 'overview' ? ( (() => { const now = new Date(); const versionReqs = scopedVersionData.requirements; const versionPlans = scopedVersionData.plans; const versionDevTasks = scopedVersionData.devTasks; const versionTCs = scopedVersionData.testCases; const versionBugs = scopedVersionData.bugs; const versionOT = scopedVersionData.overtimeRecords; 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 (
{/* 阶段概览 */} {(() => { 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 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: 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 actualReleaseDate = version.releaseDate; const versionActualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString()); let overdueDays = 0; if (deadline && actualReleaseDate) { const endDate = new Date(actualReleaseDate); 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 ? formatOverviewDateTime(deadline) : '未设置' }, { label: '实际发版日期', value: actualReleaseDate ? formatOverviewDateTime(actualReleaseDate) : '未发布' }, { 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} 天
)}
); })()}
参与人员
{!versionReadonly && (
)}
{(() => { 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 环形饼图 */} {(() => { const colors = ['#3b82f6', '#f97316', '#8b5cf6', '#10b981', '#ef4444', '#f59e0b', '#6366f1', '#ec4899']; let cumPercent = 0; return reasonRanking.map(([reasonId, hours], i) => { const percent = hours / reasonTotal; const startAngle = cumPercent * 360; cumPercent += percent; const endAngle = cumPercent * 360; const largeArc = percent > 0.5 ? 1 : 0; const startRad = ((startAngle - 90) * Math.PI) / 180; const endRad = ((endAngle - 90) * Math.PI) / 180; const x1 = 50 + 40 * Math.cos(startRad); const y1 = 50 + 40 * Math.sin(startRad); const x2 = 50 + 40 * Math.cos(endRad); const y2 = 50 + 40 * Math.sin(endRad); if (percent >= 1) { return ; } return ( ); }); })()} {/* 图例 */}
{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 = versionReqs.filter((r) => 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) => (
{i + 1} {name}
{count} 次
))}
)}
变更原因占比
{(() => { let cumPercent = 0; return changeReasonRanking.map(([reasonId, count], i) => { const percent = count / reasonTotal; const startAngle = cumPercent * 360; cumPercent += percent; const endAngle = cumPercent * 360; const largeArc = percent > 0.5 ? 1 : 0; const startRad = ((startAngle - 90) * Math.PI) / 180; const endRad = ((endAngle - 90) * Math.PI) / 180; const x1 = 50 + 40 * Math.cos(startRad); const y1 = 50 + 40 * Math.sin(startRad); const x2 = 50 + 40 * Math.cos(endRad); const y2 = 50 + 40 * Math.sin(endRad); if (percent >= 1) return ; return ; }); })()}
{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' ? ( { if (versionReadonly || !requirementWriteReady || !canManageRequirements) return; ids.forEach((id) => { const requirement = displayRequirements.find((item) => item.id === id); if (!requirement) return; updateRequirement(id, { productId: requirement.productId, ...getRequirementVersionSelectionPatch(requirement, version.id, addedBy), }); }); }} onUnlink={(id) => { if (versionReadonly || !requirementWriteReady || !canManageRequirements) return; const requirement = displayRequirements.find((item) => item.id === id); if (!requirement) return; updateRequirement(id, { productId: requirement.productId, ...getRequirementVersionUnlinkPatch(requirement), }); }} onCreateChange={(data) => { if (versionReadonly || !requirementWriteReady || !canManageRequirements) return; 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 versionLinkedReqs = getVersionLinkedRequirementCandidates(displayRequirements, displayRequirementIds); return ( { if (versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])) return; createPlan(data); if ((pt === 'product') && data.linkedRequirementIds?.length) { data.linkedRequirementIds.forEach((rid) => { const requirement = displayRequirements.find((item) => item.id === rid); if (requirement) updateRequirement(rid, { productId: requirement.productId, 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) => { if (versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])) return; updatePlan(id, data); if ((pt === 'product') && data.linkedRequirementIds && data.owner) { data.linkedRequirementIds.forEach((rid) => { const requirement = displayRequirements.find((item) => item.id === rid); if (requirement) updateRequirement(rid, { productId: requirement.productId, productOwner: data.owner }); }); } }} onComplete={(id, result) => { if (versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])) return; return completePlan(id, result); }} onDelete={(id) => { if (versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])) return; deletePlan(id); }} /> ); })() ) : activeTab === 'tasks' ? ( ) : activeTab === 'testcases' ? ( ) : activeTab === 'bugs' ? ( ) : (
功能开发中,敬请期待
)}
{showEditModal && !versionReadonly && ( { updateVersion(version.productId, version.id, data); setShowEditModal(false); }} onClose={() => setShowEditModal(false)} /> )} {showRecommendModal && !versionReadonly && ( { const recommendations = memberRecommendationGroups.flatMap((group) => group.items); const newMembers = addRecommendedVersionMembers(version.members ?? [], selectedNames, recommendations); updateVersion(version.productId, version.id, { members: newMembers }); setShowRecommendModal(false); }} onClose={() => setShowRecommendModal(false)} /> )} {showReleaseModal && !versionReadonly && ( { updateVersion(version.productId, version.id, { status: 'released', releaseDate }); setShowReleaseModal(false); }} onClose={() => setShowReleaseModal(false)} /> )} {/* 参与人员设置弹窗 */} {showMemberModal && !versionReadonly && ( { updateVersion(version.productId, version.id, { members: newMembers }); setShowMemberModal(false); }} onClose={() => setShowMemberModal(false)} /> )} setShowAnalysisDrawer(false)} />
); } const PRIORITY_OPTIONS: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4']; function toDateInputValue(value?: string | null): string { if (!value) return ''; return value.includes('T') ? value.slice(0, 10) : value; } function VersionEditModal({ version, onSubmit, onClose }: { version: VersionWithContext; onSubmit: (data: { name: string; priority: Priority; expectedReleaseDate: string }) => void; onClose: () => void; }) { const [versionNumber, setVersionNumber] = useState(() => getEditableVersionNumber(version.projectName, version.name)); const [priority, setPriority] = useState((version.priority ?? 'P2') as Priority); const [developmentType, setDevelopmentType] = useState(''); const [productDesignCompleted, setProductDesignCompleted] = useState(false); const [expectedReleaseDate, setExpectedReleaseDate] = useState(() => toDateInputValue(version.expectedReleaseDate)); const [submitting, setSubmitting] = useState(false); const selectedDevelopmentType = developmentType ? getVersionDevelopmentTypeOption(developmentType) : undefined; const recommendedWorkDays = selectedDevelopmentType ? getRecommendedWorkDays(selectedDevelopmentType, { productDesignCompleted }) : 0; const canSubmit = canSubmitVersionEditForm({ versionNumber, expectedReleaseDate, submitting }); const handleVersionInput = (value: string) => { setVersionNumber(value.replace(/[^\d.]/g, '')); }; const applyRecommendedReleaseDate = (type: VersionDevelopmentType, productDesigned: boolean) => { setExpectedReleaseDate(getRecommendedExpectedReleaseDate(type, new Date(), { productDesignCompleted: productDesigned })); }; const handleDevelopmentTypeChange = (type: VersionDevelopmentType) => { setDevelopmentType(type); applyRecommendedReleaseDate(type, productDesignCompleted); }; const handleProductDesignCompletedChange = (completed: boolean) => { setProductDesignCompleted(completed); if (developmentType) applyRecommendedReleaseDate(developmentType, completed); }; const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (!canSubmit) return; setSubmitting(true); onSubmit({ name: buildVersionName(version.projectName, versionNumber), priority, expectedReleaseDate, }); }; return (
event.stopPropagation()} >

编辑版本

{version.productName} / {version.projectName}

优先级
{PRIORITY_OPTIONS.map((item) => ( ))}
项目开发类型
{VERSION_DEVELOPMENT_TYPE_OPTIONS.map((option) => ( ))}
产品设计是否已完成
); } function ReleaseVersionModal({ progress, initialDate, onSubmit, onClose }: { progress: number; initialDate: string; onSubmit: (releaseDate: string) => void; onClose: () => void; }) { const [releaseDate, setReleaseDate] = useState(initialDate); const warning = getReleaseProgressWarning(progress); const canSubmit = canSubmitReleaseForm(releaseDate); const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (!canSubmit) return; onSubmit(releaseDate.trim()); }; return (
event.stopPropagation()} >

发版

填写实际发版日期后,版本状态会更新为已发布。

{warning ? (
{warning}
) : (
当前版本进度 100%,可记录实际发版日期。
)}
); } 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 formatRecommendationHours(hours: number): string { if (hours <= 0) return '0h'; return `${Math.round(hours * 10) / 10}h`; } function formatAvailableDays(days: number): string { if (days <= 0) return '今天'; return `约${days}天`; } function getRecommendationScoreClass(score: number): string { if (score >= 80) return 'bg-emerald-50 text-emerald-700 border-emerald-100'; if (score >= 65) return 'bg-blue-50 text-blue-700 border-blue-100'; if (score >= 50) return 'bg-amber-50 text-amber-700 border-amber-100'; return 'bg-red-50 text-red-700 border-red-100'; } function getRecommendationBarClass(score: number): string { if (score >= 80) return 'bg-emerald-500'; if (score >= 65) return 'bg-blue-500'; if (score >= 50) return 'bg-amber-500'; return 'bg-red-500'; } function getInitialRecommendationSelection(groups: MemberRecommendationGroup[]): Set { return new Set(getDefaultRecommendedMemberNames(groups)); } function MemberRecommendationModal({ groups, members, onAdd, onClose }: { groups: MemberRecommendationGroup[]; members: { role: Role; name: string }[]; onAdd: (selectedNames: string[]) => void; onClose: () => void; }) { const allItems = groups.flatMap((group) => group.items); const [selectedNames, setSelectedNames] = useState>(() => getInitialRecommendationSelection(groups)); const hasHistoricalSignal = allItems.some((item) => item.confidence === 'high'); const toggleName = (name: string) => { setSelectedNames((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); }; const handleAdd = () => { if (selectedNames.size === 0) return; onAdd(Array.from(selectedNames)); }; return (
e.stopPropagation()}>

AI推荐参与人员

{!hasHistoricalSignal && allItems.length > 0 && ( 历史数据待导入 )}

当前 {members.length} 人已加入,推荐 {allItems.length} 人,已选 {selectedNames.size} 人

{allItems.length === 0 ? (
暂无可推荐人员
当前推荐缺口已满足,或暂无符合前端、后端、测试部门且未加入版本的候选人。
) : (
{groups.map((group) => (
{RECOMMENDATION_ROLE_LABEL[group.role]} 建议 {group.requiredCount} / 已有 {group.currentCount} / 缺 {group.missingCount}
范围 {formatRecommendationHours(group.scopeHours)}
{group.items.map((item) => { const selected = selectedNames.has(item.name); const workloadCount = item.metrics.activeTaskCount + item.metrics.activeBugCount; const stats = [ { label: '剩余', value: formatRecommendationHours(item.metrics.remainingHours) }, { label: '可接入', value: formatAvailableDays(item.metrics.availableInDays) }, { label: '事项', value: `${workloadCount}项` }, { label: '加班', value: formatRecommendationHours(item.metrics.recentOvertimeHours) }, ]; return ( ); })}
))}
)}
); } 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} 人
{list.length === 0 ? (
暂无成员
) : (
{list.map((member) => (
{member.name} handleRoleChange(member.name, value as Role)} options={roleOptions} showAllOption={false} className="w-24 shrink-0" />
))}
)}
); }