'use client'; import { RouteGuard } from '@/components/auth/Guard'; import { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Search, Tag, Plus, X, ChevronDown, MoreHorizontal, Pause, Play, XCircle, Trash2, Layers, Package, FolderKanban } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; import { flattenVersions } from '@/lib/derive'; import type { VersionWithContext } from '@/lib/derive'; import { VersionStatus } from '@/lib/version-status'; import { ROLE_LABEL } from '@/lib/stage'; import { buildVersionProgressMap } from '@/lib/version-progress'; import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; import { getRiskScoreTone } from '@/lib/xiaobao-warning-view'; import { buildVersionScopeTree, filterVersionsForList, type VersionListScope, type VersionProductNode } from '@/lib/version-list'; import { getRequirementVersionUnlinkPatch } from '@/lib/requirement-version-link'; import { VERSION_DEVELOPMENT_TYPE_OPTIONS, canSubmitNewVersionForm, getRecommendedExpectedReleaseDate, getRecommendedWorkDays, getVersionDevelopmentTypeOption, type VersionDevelopmentType, } from '@/lib/version-form'; import { Pagination, usePagination } from '@/components/Pagination'; type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4'; const PRIORITY_COLORS: Record = { 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 PRIORITY_ORDER: Record = { P0: 0, P1: 1, P2: 2, P3: 3, P4: 4, }; const RISK_SCORE_TEXT_CLASS = { danger: 'text-red-600', warn: 'text-orange-600', ok: 'text-emerald-600', } as const; const RISK_SCORE_DOT_CLASS = { danger: 'bg-red-500', warn: 'bg-orange-500', ok: 'bg-emerald-500', } as const; function sortVersions(versions: VersionWithContext[]): VersionWithContext[] { return [...versions].sort((a, b) => { // Sort by priority desc (P0 first) const pa = PRIORITY_ORDER[(a.priority ?? 'P2') as Priority] ?? 2; const pb = PRIORITY_ORDER[(b.priority ?? 'P2') as Priority] ?? 2; if (pa !== pb) return pa - pb; // Then by createdAt desc return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); }); } export default function VersionsPage() { return ( ); } function VersionsPageContent() { const router = useRouter(); const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore(); const [versionKeyword, setVersionKeyword] = useState(''); const [treeKeyword, setTreeKeyword] = useState(''); const [selectedScope, setSelectedScope] = useState({ type: 'all' }); const [priorityFilter, setPriorityFilter] = useState('all'); const [priorityDropdownOpen, setPriorityDropdownOpen] = useState(false); const [showModal, setShowModal] = useState(false); const [openMenuId, setOpenMenuId] = useState(null); useEffect(() => { fetchOverview(); }, [fetchOverview]); const { requirements, fetchRequirements, updateRequirement } = useRequirementStore(); useEffect(() => { fetchRequirements(); }, [fetchRequirements]); const { plans, fetchPlans } = useVersionPlanStore(); useEffect(() => { fetchPlans(); }, [fetchPlans]); const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore(); useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]); const { testCases, fetchTestCases } = useTestCaseStore(); useEffect(() => { fetchTestCases(); }, [fetchTestCases]); const user = useAuthStore((s) => s.user); const currentUserName = user?.name || ''; const { roles } = useMemberStore(); const isSuperAdmin = useMemo(() => { const r = roles.find((x) => x.id === user?.roleId); return !!r && r.permissions.includes('*'); }, [roles, user?.roleId]); const allVersionsRaw = useMemo(() => flattenVersions(overview), [overview]); const { risks: xiaobaoRisks } = useXiaobaoWarningRisks(); const xiaobaoRiskMap = useMemo( () => new Map(xiaobaoRisks.map((risk) => [risk.versionId, risk])), [xiaobaoRisks], ); // 只显示当前用户参与的版本(members为空时所有人可见;超管可见全部) const allVersions = useMemo(() => allVersionsRaw.filter((v) => { if (isSuperAdmin) return true; if (!v.members || v.members.length === 0) return true; return v.members.some((m) => m.name === currentUserName); }), [allVersionsRaw, currentUserName, isSuperAdmin]); const versionTree = useMemo(() => buildVersionScopeTree(allVersions, treeKeyword), [allVersions, treeKeyword]); // Compute overall progress per version from actual data const versionProgressMap = useMemo( () => buildVersionProgressMap(allVersions, plans, requirements, devTasks, testCases), [allVersions, plans, requirements, devTasks, testCases], ); const filtered = useMemo(() => { const list = filterVersionsForList(allVersions, { scope: selectedScope, keyword: versionKeyword, priority: priorityFilter as Priority | 'all', }); return sortVersions(list); }, [allVersions, selectedScope, versionKeyword, priorityFilter]); const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20); const selectedScopeLabel = useMemo(() => { if (selectedScope.type === 'product') { return allVersions.find((version) => version.productId === selectedScope.productId)?.productName ?? '产品'; } if (selectedScope.type === 'project') { const found = allVersions.find((version) => version.projectId === selectedScope.projectId); return found ? `${found.productName} / ${found.projectName}` : '项目'; } return '全部版本'; }, [allVersions, selectedScope]); const handleAction = async (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => { setOpenMenuId(null); if (action === 'delete') { if (!confirm('确认删除该版本?关联的需求会回到需求池。')) return; requirements .filter((r) => r.versionId === version.id) .forEach((r) => updateRequirement(r.id, { productId: r.productId, ...getRequirementVersionUnlinkPatch(r) })); deleteVersion(version.productId, version.id); return; } const statusMap: Record = { pause: 'paused', resume: 'developing', close: 'closed', }; if (updateVersion) { await updateVersion(version.productId, version.id, { status: statusMap[action] }); } }; return (
{ setSelectedScope(scope); setPage(1); }} />

版本列表

{filtered.length}

{selectedScopeLabel}

{ setVersionKeyword(event.target.value); setPage(1); }} placeholder="搜索版本号" className="h-8 w-72 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" />
{priorityDropdownOpen && ( <>
setPriorityDropdownOpen(false)} />
{(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((priority) => ( ))}
)}
{filtered.length === 0 ? (

没有找到匹配的版本

尝试调整左侧范围或右侧筛选

) : ( <>
{paged.map((version) => ( router.push(`/versions/${version.id}`)} onAction={handleAction} /> ))}
版本号 优先级 整体进度 风险分 期望发版 负责人 操作
)}
{showModal && setShowModal(false)} onCreate={createVersion} currentUserName={currentUserName} />}
); } interface VersionRowProps { version: VersionWithContext; overallProgress: number; xiaobaoRisk?: XiaobaoVersionRisk; openMenuId: string | null; setOpenMenuId: (id: string | null) => void; onNavigate: () => void; onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => void; } function VersionScopeSidebar({ tree, totalCount, keyword, selectedScope, onKeywordChange, onSelectScope }: { tree: VersionProductNode[]; totalCount: number; keyword: string; selectedScope: VersionListScope; onKeywordChange: (value: string) => void; onSelectScope: (scope: VersionListScope) => void; }) { const allActive = selectedScope.type === 'all'; return ( ); } function VersionRow({ version, overallProgress, xiaobaoRisk, openMenuId, setOpenMenuId, onNavigate, onAction }: VersionRowProps) { const priority = (version.priority ?? 'P2') as Priority; const riskScoreTone = xiaobaoRisk ? getRiskScoreTone(xiaobaoRisk.riskScore) : null; const isMenuOpen = openMenuId === version.id; // Members: show first 2 with role label, then +N const members = version.members ?? []; const displayMembers = members.slice(0, 2); const extraCount = members.length - 2; return ( {/* 版本号 */} {/* 优先级 */} {priority} {/* 整体进度 */}
{overallProgress}%
{/* 风险分 */} {xiaobaoRisk && riskScoreTone ? ( {xiaobaoRisk.riskScore} ) : ( - )} {/* 截止日期 */} {version.expectedReleaseDate ? new Date(version.expectedReleaseDate).toISOString().slice(0, 10) : '-'} {/* 负责人 */}
{displayMembers.map((m, i) => ( {ROLE_LABEL[m.role]}:{m.name} ))} {extraCount > 0 && ( +{extraCount} )} {members.length === 0 && -}
{/* 操作 */}
{isMenuOpen && ( <>
setOpenMenuId(null)} />
{version.status === 'developing' && ( )} {version.status === 'paused' && ( )} {version.status !== 'closed' && version.status !== 'released' && ( )} {version.status === 'planned' && ( )}
)}
); } type IterationType = 'major' | 'minor' | 'patch'; interface NewVersionModalProps { overview: any[]; onClose: () => void; onCreate: (productId: string, data: { name: string; status: VersionStatus; priority?: Priority; expectedReleaseDate: string; members?: any[] }) => void; currentUserName: string; } function getNextVersion(existingVersions: any[], projectName: string, type: IterationType): string { const projectVersions = existingVersions .filter((v: any) => v.name.startsWith(projectName + 'V')) .map((v: any) => { const match = v.name.match(/V([\d.]+)$/i); return match ? match[1].split('.').map(Number) : null; }) .filter(Boolean) as number[][]; if (projectVersions.length === 0) { if (type === 'major') return '1.0'; if (type === 'minor') return '0.1'; return '0.0.1'; } // Find the max version projectVersions.sort((a, b) => { const len = Math.max(a.length, b.length); for (let i = 0; i < len; i++) { const diff = (b[i] ?? 0) - (a[i] ?? 0); if (diff !== 0) return diff; } return 0; }); const latest = projectVersions[0]; if (type === 'major') { return `${(latest[0] ?? 0) + 1}.0`; } else if (type === 'minor') { return `${latest[0] ?? 0}.${(latest[1] ?? 0) + 1}`; } else { const major = latest[0] ?? 0; const minor = latest[1] ?? 0; const patch = latest[2] ?? 0; return `${major}.${minor}.${patch + 1}`; } } function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVersionModalProps) { const [productId, setProductId] = useState(''); const [projectId, setProjectId] = useState(''); const [projectName, setProjectName] = useState(''); const [iterationType, setIterationType] = useState(''); const [versionNumber, setVersionNumber] = useState(''); const [developmentType, setDevelopmentType] = useState(''); const [productDesignCompleted, setProductDesignCompleted] = useState(false); const [status] = useState('developing'); const [priority, setPriority] = useState('P2'); const [expectedReleaseDate, setExpectedReleaseDate] = useState(''); const [submitting, setSubmitting] = useState(false); const selectedProduct = overview.find((p: any) => p.id === productId); const projects = selectedProduct?.projects ?? []; const existingVersions = selectedProduct?.versions ?? []; // When product changes, reset project const handleProductChange = (pid: string) => { setProductId(pid); setProjectId(''); setProjectName(''); setIterationType(''); setVersionNumber(''); setDevelopmentType(''); setProductDesignCompleted(false); setExpectedReleaseDate(''); }; // When project changes const handleProjectChange = (projId: string) => { const proj = projects.find((p: any) => p.id === projId); setProjectId(projId); setProjectName(proj?.name ?? ''); setIterationType(''); setVersionNumber(''); setDevelopmentType(''); setProductDesignCompleted(false); setExpectedReleaseDate(''); }; // When iteration type changes, auto-generate version number const handleIterationChange = (type: IterationType) => { setIterationType(type); if (projectName) { const next = getNextVersion(existingVersions, projectName, type); setVersionNumber(next); } }; const handleVersionInput = (val: string) => { // Only allow digits and dots const cleaned = val.replace(/[^\d.]/g, ''); setVersionNumber(cleaned); }; 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 selectedDevelopmentType = developmentType ? getVersionDevelopmentTypeOption(developmentType) : undefined; const recommendedWorkDays = selectedDevelopmentType ? getRecommendedWorkDays(selectedDevelopmentType, { productDesignCompleted }) : 0; const canSubmit = canSubmitNewVersionForm({ productId, projectId, versionNumber, developmentType, expectedReleaseDate, submitting, }); const handleSubmit = async () => { if (!canSubmit) return; setSubmitting(true); try { await onCreate(productId, { name: `${projectName}V${versionNumber}`, status, priority, expectedReleaseDate, members: currentUserName ? [{ role: 'product', name: currentUserName }] : [], }); onClose(); } catch (e) { setSubmitting(false); } }; return (
e.stopPropagation()}>

新建版本

{/* 产品选择 */}
{/* 项目选择 */}
{/* 迭代类型 */}
{([ { key: 'major' as const, label: '大版本', hint: 'X.0' }, { key: 'minor' as const, label: '中版本', hint: '1.X' }, { key: 'patch' as const, label: '小版本', hint: '1.2.X' }, ]).map((opt) => ( ))}
{/* 版本号 */}
handleVersionInput(e.target.value)} placeholder="1.0" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] tabular-nums text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" /> {projectName && versionNumber && (

将创建:{projectName}V{versionNumber}

)}
{/* 状态 */} {/* 优先级 */}
{(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((p) => ( ))}
{/* 项目开发类型 */}
{VERSION_DEVELOPMENT_TYPE_OPTIONS.map((opt) => ( ))}
{/* 产品设计是否已完成 */}
{/* 期望发版日期 */}
setExpectedReleaseDate(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" /> {selectedDevelopmentType && (

推荐周期 {recommendedWorkDays} 个工作日: {selectedDevelopmentType.stages.map((stage, index) => { const skipped = productDesignCompleted && stage.key === 'product_design'; return ( {index > 0 && ' + '} {stage.label}{stage.workDays}天 ); })}

)}
); }