Files
ftb-project-management/apps/web/app/versions/page.tsx
Script Generator 342fff7f06 refactor: 去掉"联调"阶段,新增"BUG"阶段胶囊
- Stage 类型去掉 integration,新增 bug
- 胶囊条:调研 → 产品设计 → UI设计 → 开发 → 测试 → BUG → 上线
- BUG 阶段进度 = 已关闭Bug / 总Bug
- 全部关闭 = done(绿色),有Bug未关闭 = active(蓝色)
- 版本列表/项目详情的阶段筛选同步去掉联调

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 10:18:17 +08:00

752 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
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 { 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 { STATUS_PROGRESS } from '@/lib/dev-task';
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<Priority, string> = {
P0: 'bg-red-500/10 text-red-600',
P1: 'bg-orange-500/10 text-orange-600',
P2: 'bg-blue-500/10 text-blue-600',
P3: 'bg-zinc-100 text-zinc-600',
P4: 'bg-zinc-100 text-zinc-500',
};
const PRIORITY_ORDER: Record<Priority, number> = {
P0: 0,
P1: 1,
P2: 2,
P3: 3,
P4: 4,
};
const STATUS_TABS: { key: string; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'requirement', label: '调研' },
{ key: 'product_design', label: '产品设计' },
{ key: 'ui_design', label: 'UI设计' },
{ key: 'dev', label: '开发' },
{ key: 'testing', label: '测试' },
{ key: 'released', label: '已发布' },
{ key: 'paused', label: '已暂停' },
{ key: 'closed', label: '已关闭' },
{ key: 'planned', label: '规划中' },
];
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<string, string[]> = {
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() {
const router = useRouter();
const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
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<string | null>(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 allVersions = useMemo(() => flattenVersions(overview), [overview]);
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
// Compute overall progress per version from actual data
const versionProgressMap = useMemo(() => {
const map: Record<string, number> = {};
for (const v of allVersions) {
const vPlans = plans.filter((p) => p.versionId === v.id);
const vReqs = requirements.filter((r) => r.versionId === v.id);
const vReqIds = new Set(vReqs.map((r) => r.id));
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
const vTestCases = testCases.filter((c) => c.versionId === v.id);
const segments: number[] = [];
// Research plans progress
const researchPlans = vPlans.filter((p) => p.type === 'research');
if (researchPlans.length > 0) {
const totals = researchPlans.reduce((acc, p) => {
const tasks = p.tasks || [];
acc.total += tasks.length;
acc.done += tasks.filter((t) => t.status === 'completed').length;
return acc;
}, { total: 0, done: 0 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
// Product plans progress
const productPlans = vPlans.filter((p) => p.type === 'product');
if (productPlans.length > 0) {
const totals = productPlans.reduce((acc, p) => {
const linked = p.linkedRequirementIds || [];
const completed = p.completedRequirementIds || [];
acc.total += linked.length;
acc.done += completed.filter((id) => linked.includes(id)).length;
return acc;
}, { total: 0, done: 0 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
// UI plans progress
const uiPlans = vPlans.filter((p) => p.type === 'ui');
if (uiPlans.length > 0) {
const totals = uiPlans.reduce((acc, p) => {
const linked = p.linkedRequirementIds || [];
const completed = p.completedRequirementIds || [];
acc.total += linked.length;
acc.done += completed.filter((id) => linked.includes(id)).length;
return acc;
}, { total: 0, done: 0 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
// Dev tasks progress (weighted by STATUS_PROGRESS)
if (vDevTasks.length > 0) {
const totalEstimate = vDevTasks.reduce((sum, t) => sum + t.estimateHours, 0);
let devProgress: number;
if (totalEstimate === 0) {
devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length;
} else {
const weighted = vDevTasks.reduce((sum, t) => sum + t.estimateHours * STATUS_PROGRESS[t.status], 0);
devProgress = weighted / totalEstimate;
}
segments.push(devProgress);
}
// Test cases progress (executed / total)
if (vTestCases.length > 0) {
const executed = vTestCases.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
segments.push((executed / vTestCases.length) * 100);
}
map[v.id] = segments.length > 0 ? Math.round(segments.reduce((s, x) => s + x, 0) / segments.length) : 0;
}
return map;
}, [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 (statusFilter !== 'all') {
if (['planned', 'released', 'paused', 'closed'].includes(statusFilter)) {
list = list.filter(v => v.status === statusFilter);
} else {
list = list.filter(v => v.status === 'developing' && v.currentStage === statusFilter);
}
}
if (priorityFilter !== 'all') {
list = list.filter(v => (v.priority ?? 'P2') === priorityFilter);
}
return sortVersions(list);
}, [allVersions, search, statusFilter, 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<string, VersionStatus> = {
pause: 'paused',
resume: 'developing',
close: 'closed',
};
if (updateVersion) {
await updateVersion(version.productId, version.id, { status: statusMap[action] });
}
};
return (
<div className="flex h-full flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="flex items-center gap-2.5">
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]"></h1>
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{allVersions.length}</span>
</div>
<button onClick={() => setShowModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
</header>
{/* Filters */}
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
<input value={search} onChange={(e) => 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)]" />
</div>
<div className="flex flex-wrap gap-1">
{STATUS_TABS.map((tab) => (
<button key={tab.key} onClick={() => setStatusFilter(tab.key)} className={`h-8 rounded-lg px-3 text-[12px] transition-colors ${statusFilter === tab.key ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'}`}>
{tab.label}
</button>
))}
</div>
{/* Project dropdown */}
<div className="relative">
<button onClick={() => setProjectDropdownOpen(!projectDropdownOpen)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
{projectFilter === 'all' ? '全部项目' : projectFilter}
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</button>
{projectDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setProjectDropdownOpen(false)} />
<div className="absolute left-0 top-full z-20 mt-1 min-w-[140px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
<button onClick={() => { setProjectFilter('all'); setProjectDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${projectFilter === 'all' ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
</button>
{allProjects.map((p) => (
<button key={p.id} onClick={() => { setProjectFilter(p.name); setProjectDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${projectFilter === p.name ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
{p.name}
</button>
))}
</div>
</>
)}
</div>
{/* Priority dropdown */}
<div className="relative">
<button onClick={() => setPriorityDropdownOpen(!priorityDropdownOpen)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
{priorityFilter === 'all' ? '全部优先级' : priorityFilter}
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</button>
{priorityDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setPriorityDropdownOpen(false)} />
<div className="absolute left-0 top-full z-20 mt-1 min-w-[100px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
<button onClick={() => { setPriorityFilter('all'); setPriorityDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${priorityFilter === 'all' ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
</button>
{(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((p) => (
<button key={p} onClick={() => { setPriorityFilter(p); setPriorityDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${priorityFilter === p ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
{p}
</button>
))}
</div>
</>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto bg-[var(--bg)]">
<div className="px-5 py-4">
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
<p className="text-[13px] font-medium text-[var(--ink-soft)]"></p>
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]"></p>
</div>
) : (
<>
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]">
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
</tr>
</thead>
<tbody>
{paged.map((v) => (
<VersionRow
key={v.id}
version={v}
overallProgress={versionProgressMap[v.id] ?? 0}
openMenuId={openMenuId}
setOpenMenuId={setOpenMenuId}
onNavigate={() => router.push(`/versions/${v.id}`)}
onAction={handleAction}
/>
))}
</tbody>
</table>
</div>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
</>
)}
</div>
</div>
{showModal && <NewVersionModal overview={overview} onClose={() => setShowModal(false)} onCreate={createVersion} />}
</div>
);
}
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 (
<tr className="border-b border-[var(--line-soft)] last:border-0 transition-colors hover:bg-[var(--bg-subtle)]">
{/* 版本号 */}
<td className="px-4 py-3">
<button onClick={onNavigate} className="flex items-center gap-2 font-medium text-[var(--ink)] hover:text-[var(--accent)] transition-colors">
<Tag className="h-3.5 w-3.5 text-[var(--ink-muted)]" strokeWidth={1.75} />
{version.name}
</button>
</td>
{/* 优先级 */}
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${PRIORITY_COLORS[priority]}`}>
{priority}
</span>
</td>
{/* 状态 */}
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${VERSION_STATUS_BG[version.status as VersionStatus] || 'bg-zinc-100 text-zinc-600'}`}>
{getVersionDisplayStatus(version.status as VersionStatus, version.currentStage)}
</span>
</td>
{/* 整体进度 */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 rounded-full bg-zinc-100">
<div
className="h-1.5 rounded-full bg-blue-500 transition-all"
style={{ width: `${overallProgress}%` }}
/>
</div>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{overallProgress}%</span>
</div>
</td>
{/* 健康度 */}
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 text-[12px] font-semibold tabular-nums ${HEALTH_LEVEL_COLOR[healthLevel]}`}>
<span className={`h-2 w-2 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
{healthScore}
</span>
</td>
{/* 风险标签 */}
<td className="px-4 py-3">
{riskTags.length === 0 ? (
<span className="text-[11px] text-[var(--ink-muted)]">-</span>
) : (
<div className="flex flex-wrap items-center gap-1">
{riskTags.slice(0, 3).map((tag) => (
<span key={tag.key} className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
))}
{riskTags.length > 3 && (
<span className="text-[10px] text-[var(--ink-muted)]">+{riskTags.length - 3}</span>
)}
</div>
)}
</td>
{/* 截止日期 */}
<td className="px-4 py-3 text-[var(--ink-muted)] tabular-nums">
{version.expectedReleaseDate
? new Date(version.expectedReleaseDate).toISOString().slice(0, 10)
: '-'}
</td>
{/* 负责人 */}
<td className="px-4 py-3">
<div className="flex items-center gap-1 text-[11px] text-[var(--ink-soft)]">
{displayMembers.map((m, i) => (
<span key={i} className="whitespace-nowrap">
{ROLE_LABEL[m.role]}:{m.name}
</span>
))}
{extraCount > 0 && (
<span className="whitespace-nowrap text-[var(--ink-muted)]">+{extraCount}</span>
)}
{members.length === 0 && <span className="text-[var(--ink-muted)]">-</span>}
</div>
</td>
{/* 操作 */}
<td className="px-4 py-3">
<div className="relative">
<button
onClick={() => setOpenMenuId(isMenuOpen ? null : version.id)}
className="rounded-lg p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-hover)] transition-colors"
>
<MoreHorizontal className="h-4 w-4" strokeWidth={1.75} />
</button>
{isMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpenMenuId(null)} />
<div className="absolute right-0 top-full z-20 mt-1 min-w-[120px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
{version.status === 'developing' && (
<button
onClick={() => onAction(version, 'pause')}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-hover)] transition-colors"
>
<Pause className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
)}
{version.status === 'paused' && (
<button
onClick={() => onAction(version, 'resume')}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-hover)] transition-colors"
>
<Play className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
)}
{version.status !== 'closed' && version.status !== 'released' && (
<button
onClick={() => onAction(version, 'close')}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-red-600 hover:bg-[var(--bg-hover)] transition-colors"
>
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
)}
{version.status === 'planned' && (
<button
onClick={() => onAction(version, 'delete')}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-red-600 hover:bg-[var(--bg-hover)] transition-colors"
>
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
)}
</div>
</>
)}
</div>
</td>
</tr>
);
}
type IterationType = 'major' | 'minor' | 'patch';
interface NewVersionModalProps {
overview: any[];
onClose: () => void;
onCreate: (productId: string, data: { name: string; status: VersionStatus; priority?: Priority; expectedReleaseDate?: string }) => void;
}
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 }: NewVersionModalProps) {
const [productId, setProductId] = useState('');
const [projectId, setProjectId] = useState('');
const [projectName, setProjectName] = useState('');
const [iterationType, setIterationType] = useState<IterationType | ''>('');
const [versionNumber, setVersionNumber] = useState('');
const [status, setStatus] = useState<VersionStatus>('planned');
const [priority, setPriority] = useState<Priority>('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 } : {}),
});
onClose();
} catch (e) {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-[15px] font-semibold text-[var(--ink)]"></h2>
<button onClick={onClose} className="rounded-lg p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-hover)] transition-colors">
<X className="h-4 w-4" strokeWidth={2} />
</button>
</div>
<div className="space-y-4">
{/* 产品选择 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></label>
<select value={productId} onChange={(e) => handleProductChange(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)]">
<option value=""></option>
{overview.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{/* 项目选择 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></label>
<select value={projectId} onChange={(e) => handleProjectChange(e.target.value)} disabled={!productId} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] disabled:opacity-50 focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]">
<option value=""></option>
{projects.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{/* 迭代类型 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></label>
<div className="grid grid-cols-3 gap-2">
{([
{ 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) => (
<button key={opt.key} onClick={() => handleIterationChange(opt.key)} disabled={!projectId} className={`flex flex-col items-center gap-0.5 rounded-lg border px-3 py-2 transition-colors disabled:opacity-50 ${iterationType === opt.key ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}>
<span className="text-[12px] font-medium">{opt.label}</span>
<span className="text-[10px] text-[var(--ink-muted)] tabular-nums">{opt.hint}</span>
</button>
))}
</div>
</div>
{/* 版本号 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<input value={versionNumber} onChange={(e) => 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 && (
<p className="mt-1.5 text-[11px] text-[var(--ink-muted)]"><span className="font-medium text-[var(--ink-soft)]">{projectName}V{versionNumber}</span></p>
)}
</div>
{/* 状态 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<div className="flex gap-2">
{([
{ key: 'planned' as VersionStatus, label: '规划中' },
{ key: 'developing' as VersionStatus, label: '开发中' },
]).map((opt) => (
<button key={opt.key} onClick={() => setStatus(opt.key)} className={`flex-1 h-9 rounded-lg border px-3 text-[12px] transition-colors ${status === opt.key ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}>
{opt.label}
</button>
))}
</div>
</div>
{/* 优先级 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<div className="grid grid-cols-3 gap-2">
{(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((p) => (
<button key={p} onClick={() => setPriority(p)} className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${priority === p ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}>
{p}
</button>
))}
</div>
</div>
{/* 截止日期 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<input
type="date"
value={expectedReleaseDate}
onChange={(e) => 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)]"
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<button onClick={onClose} className="h-9 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-4 text-[13px] text-[var(--ink-soft)] hover:bg-[var(--bg-hover)] transition-colors"></button>
<button onClick={handleSubmit} disabled={!canSubmit} className="h-9 rounded-lg bg-[var(--accent)] px-4 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{submitting ? '提交中…' : '创建'}
</button>
</div>
</div>
</div>
);
}