'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 } 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, flattenProjects } from '@/lib/derive'; import type { VersionWithContext } from '@/lib/derive'; import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status'; import { STAGES } from '@/lib/stage'; import { ROLE_LABEL } from '@/lib/stage'; import { buildVersionProgressMap } from '@/lib/version-progress'; import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, getTagStyle } from '@/lib/health'; 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, }; function parseVersionNumber(name: string): number[] { const match = name.match(/V([\d.]+)$/i); if (!match) return [0]; return match[1].split('.').map(Number); } function compareVersionsDesc(a: string, b: string): number { const va = parseVersionNumber(a); const vb = parseVersionNumber(b); const len = Math.max(va.length, vb.length); for (let i = 0; i < len; i++) { const na = va[i] ?? 0; const nb = vb[i] ?? 0; if (nb !== na) return nb - na; } return 0; } 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(); }); } function getStageLabel(version: VersionWithContext): string { return getVersionDisplayStatus(version.status as VersionStatus, version.currentStage); } const STAGE_ROLE_MAP: Record = { requirement: ['product'], product_design: ['product'], ui_design: ['ui'], dev: ['frontend', 'backend'], testing: ['testing'], released: [], }; function getStageProgress(version: VersionWithContext): number { if (!version.currentStage || !version.progress) return 0; const roles = STAGE_ROLE_MAP[version.currentStage] || []; if (roles.length === 0) return 0; const items = roles.map((r) => version.progress!.find((p) => p.role === r)).filter(Boolean); if (items.length === 0) return 0; return Math.round(items.reduce((s, i) => s + i!.percent, 0) / items.length); } export default function VersionsPage() { return ( ); } function VersionsPageContent() { const router = useRouter(); const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore(); const [search, setSearch] = useState(''); const [projectFilter, setProjectFilter] = useState('all'); const [priorityFilter, setPriorityFilter] = useState('all'); const [projectDropdownOpen, setProjectDropdownOpen] = useState(false); 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]); // 只显示当前用户参与的版本(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 allProjects = useMemo(() => flattenProjects(overview), [overview]); // 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(() => { let list = allVersions.filter((v) => { if (search && !v.name.toLowerCase().includes(search.toLowerCase())) return false; if (projectFilter !== 'all' && v.projectName !== projectFilter) return false; return true; }); if (priorityFilter !== 'all') { list = list.filter(v => (v.priority ?? 'P2') === priorityFilter); } return sortVersions(list); }, [allVersions, search, projectFilter, priorityFilter]); const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20); 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, { versionId: undefined, addedToVersionBy: undefined })); 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 (

版本

{allVersions.length}
{/* Filters */}
setSearch(e.target.value)} placeholder="搜索版本号" className="h-8 w-64 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)]" />
{/* Project dropdown */}
{projectDropdownOpen && ( <>
setProjectDropdownOpen(false)} />
{allProjects.map((p) => ( ))}
)}
{/* Priority dropdown */}
{priorityDropdownOpen && ( <>
setPriorityDropdownOpen(false)} />
{(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((p) => ( ))}
)}
{/* Content */}
{filtered.length === 0 ? (

没有找到匹配的版本

尝试调整筛选条件

) : ( <>
{paged.map((v) => ( router.push(`/versions/${v.id}`)} onAction={handleAction} /> ))}
版本号 优先级 整体进度 健康度 风险标签 截止日期 负责人 操作
)}
{showModal && setShowModal(false)} onCreate={createVersion} currentUserName={currentUserName} />}
); } interface VersionRowProps { version: VersionWithContext; overallProgress: number; openMenuId: string | null; setOpenMenuId: (id: string | null) => void; onNavigate: () => void; onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => void; } function VersionRow({ version, overallProgress, openMenuId, setOpenMenuId, onNavigate, onAction }: VersionRowProps) { const priority = (version.priority ?? 'P2') as Priority; 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 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}%
{/* 健康度 */} {healthScore} {/* 风险标签 */} {riskTags.length === 0 ? ( - ) : (
{riskTags.slice(0, 3).map((tag) => ( {tag.label} ))} {riskTags.length > 3 && ( +{riskTags.length - 3} )}
)} {/* 截止日期 */} {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 [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(''); }; // When project changes const handleProjectChange = (projId: string) => { const proj = projects.find((p: any) => p.id === projId); setProjectId(projId); setProjectName(proj?.name ?? ''); setIterationType(''); setVersionNumber(''); }; // 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 canSubmit = productId && projectId && versionNumber && !submitting; const handleSubmit = async () => { if (!canSubmit) return; setSubmitting(true); try { await onCreate(productId, { name: `${projectName}V${versionNumber}`, status, priority, ...(expectedReleaseDate ? { 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) => ( ))}
{/* 截止日期 */}
setExpectedReleaseDate(e.target.value)} 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)]" />
); }