Files
2e595c7e72
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
refactor(data): 收口关系表运行时数据源
- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读
- 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移
- 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-09 14:59:49 +08:00

817 lines
39 KiB
TypeScript
Raw Permalink 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 { 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<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 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 (
<RouteGuard permission="version:view">
<VersionsPageContent />
</RouteGuard>
);
}
function VersionsPageContent() {
const router = useRouter();
const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore();
const [versionKeyword, setVersionKeyword] = useState('');
const [treeKeyword, setTreeKeyword] = useState('');
const [selectedScope, setSelectedScope] = useState<VersionListScope>({ type: 'all' });
const [priorityFilter, setPriorityFilter] = useState('all');
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();
const { plans, fetchPlans } = useVersionPlanStore();
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
const { testCases, fetchTestCases } = useTestCaseStore();
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]);
useEffect(() => {
for (const version of allVersions) {
void fetchRequirements({ productId: version.productId, versionId: version.id });
void fetchPlans({ versionId: version.id });
void fetchDevTasks({ versionId: version.id });
void fetchTestCases({ versionId: version.id });
}
}, [allVersions, fetchDevTasks, fetchPlans, fetchRequirements, fetchTestCases]);
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<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 overflow-hidden">
<VersionScopeSidebar
tree={versionTree}
totalCount={allVersions.length}
keyword={treeKeyword}
selectedScope={selectedScope}
onKeywordChange={setTreeKeyword}
onSelectScope={(scope) => {
setSelectedScope(scope);
setPage(1);
}}
/>
<section className="flex min-w-0 flex-1 flex-col bg-[var(--bg)]">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="min-w-0">
<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)]">{filtered.length}</span>
</div>
<p className="mt-0.5 truncate text-[11px] text-[var(--ink-muted)]">{selectedScopeLabel}</p>
</div>
<button onClick={() => setShowModal(true)} className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--accent-hover)]">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
</header>
<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={versionKeyword}
onChange={(event) => { 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)]"
/>
</div>
<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)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]">
{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); setPage(1); }} 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((priority) => (
<button key={priority} onClick={() => { setPriorityFilter(priority); setPriorityDropdownOpen(false); setPage(1); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${priorityFilter === priority ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
{priority}
</button>
))}
</div>
</>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
{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="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<div className="overflow-x-auto">
<table className="min-w-[920px] 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>
</tr>
</thead>
<tbody>
{paged.map((version) => (
<VersionRow
key={version.id}
version={version}
overallProgress={versionProgressMap[version.id] ?? 0}
xiaobaoRisk={xiaobaoRiskMap.get(version.id)}
openMenuId={openMenuId}
setOpenMenuId={setOpenMenuId}
onNavigate={() => router.push(`/versions/${version.id}`)}
onAction={handleAction}
/>
))}
</tbody>
</table>
</div>
</div>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
</>
)}
</div>
</section>
{showModal && <NewVersionModal overview={overview} onClose={() => setShowModal(false)} onCreate={createVersion} currentUserName={currentUserName} />}
</div>
);
}
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 (
<aside className="flex h-full w-72 shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] px-4">
<div>
<h2 className="text-[14px] font-semibold text-[var(--ink)]"></h2>
<p className="mt-0.5 text-[11px] text-[var(--ink-muted)]"> / </p>
</div>
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{totalCount}</span>
</div>
<div className="shrink-0 border-b border-[var(--line)] p-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={keyword}
onChange={(event) => onKeywordChange(event.target.value)}
placeholder="搜索产品/项目"
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-7 text-[12px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none"
/>
{keyword && (
<button onClick={() => onKeywordChange('')} className="absolute right-1.5 top-1/2 rounded p-0.5 -translate-y-1/2 hover:bg-[var(--bg-subtle)]" title="清除">
<X className="h-3 w-3 text-[var(--ink-muted)]" />
</button>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<button
onClick={() => onSelectScope({ type: 'all' })}
className={`mb-1 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[12px] transition-colors ${allActive ? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
<Layers className="h-3.5 w-3.5" />
<span className="min-w-0 flex-1 truncate text-left"></span>
<span className="shrink-0 rounded-full bg-[var(--bg-subtle)] px-1.5 text-[10px] tabular-nums text-[var(--ink-muted)]">{totalCount}</span>
</button>
{tree.length === 0 ? (
<div className="rounded-lg border border-dashed border-[var(--line)] px-3 py-8 text-center text-[12px] text-[var(--ink-muted)]"></div>
) : (
tree.map((product) => {
const productActive = selectedScope.type === 'product' && selectedScope.productId === product.productId;
return (
<div key={product.productId} className="mb-1">
<button
onClick={() => onSelectScope({ type: 'product', productId: product.productId })}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[12px] transition-colors ${productActive ? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
<Package className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left" title={product.productName}>{product.productName}</span>
<span className="shrink-0 rounded-full bg-[var(--bg-subtle)] px-1.5 text-[10px] tabular-nums text-[var(--ink-muted)]">{product.count}</span>
</button>
<div className="mt-0.5 space-y-0.5 pl-4">
{product.projects.map((project) => {
const projectActive = selectedScope.type === 'project' && selectedScope.projectId === project.projectId;
return (
<button
key={project.projectId}
onClick={() => onSelectScope({ type: 'project', projectId: project.projectId })}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-[12px] transition-colors ${projectActive ? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]' : 'text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink-soft)]'}`}
>
<FolderKanban className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left" title={project.projectName}>{project.projectName}</span>
<span className="shrink-0 rounded-full bg-[var(--bg-subtle)] px-1.5 text-[10px] tabular-nums text-[var(--ink-muted)]">{project.count}</span>
</button>
);
})}
</div>
</div>
);
})
)}
</div>
</aside>
);
}
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 (
<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">
<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">
{xiaobaoRisk && riskScoreTone ? (
<span className={`inline-flex items-center gap-1.5 text-[12px] font-semibold tabular-nums ${RISK_SCORE_TEXT_CLASS[riskScoreTone]}`}>
<span className={`h-2 w-2 rounded-full ${RISK_SCORE_DOT_CLASS[riskScoreTone]}`} />
{xiaobaoRisk.riskScore}
</span>
) : (
<span className="text-[11px] text-[var(--ink-muted)]">-</span>
)}
</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; 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<IterationType | ''>('');
const [versionNumber, setVersionNumber] = useState('');
const [developmentType, setDevelopmentType] = useState<VersionDevelopmentType | ''>('');
const [productDesignCompleted, setProductDesignCompleted] = useState(false);
const [status] = useState<VersionStatus>('developing');
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('');
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="max-h-[90vh] w-full max-w-md overflow-y-auto 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="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)]"><span className="text-red-500">*</span></label>
<div className="grid grid-cols-2 gap-2">
{VERSION_DEVELOPMENT_TYPE_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => handleDevelopmentTypeChange(opt.key)}
type="button"
className={`rounded-lg border px-3 py-2 text-left transition-colors ${developmentType === 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="block text-[12px] font-medium">{opt.label}</span>
<span className="mt-0.5 block text-[10px] text-[var(--ink-muted)]">{opt.description}</span>
</button>
))}
</div>
</div>
{/* 产品设计是否已完成 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleProductDesignCompletedChange(false)}
className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${!productDesignCompleted ? '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)]'}`}
>
</button>
<button
type="button"
onClick={() => handleProductDesignCompletedChange(true)}
className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${productDesignCompleted ? '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)]'}`}
>
</button>
</div>
</div>
{/* 期望发版日期 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></label>
<input
type="date"
value={expectedReleaseDate}
onChange={(e) => 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 && (
<p className="mt-1.5 text-[11px] leading-5 text-[var(--ink-muted)]">
{recommendedWorkDays}
{selectedDevelopmentType.stages.map((stage, index) => {
const skipped = productDesignCompleted && stage.key === 'product_design';
return (
<span key={stage.key}>
{index > 0 && ' + '}
<span className={skipped ? 'text-[var(--ink-muted)] line-through' : ''}>
{stage.label}{stage.workDays}
</span>
</span>
);
})}
</p>
)}
</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>
);
}