feat(版本): 优化版本列表筛选体验

关键改动:

- 抽取版本列表范围树和筛选规则

- 重构版本列表页的范围选择与优先级筛选

- 配置 Next dev 忽略测试编译输出,避免刷新干扰

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-07-01 14:32:56 +08:00
parent d2a07b0da2
commit 06b4aad7a4
5 changed files with 375 additions and 154 deletions

View File

@@ -3,7 +3,7 @@
import { RouteGuard } from '@/components/auth/Guard'; import { RouteGuard } from '@/components/auth/Guard';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Search, Tag, Plus, X, ChevronDown, MoreHorizontal, Pause, Play, XCircle, Trash2 } from 'lucide-react'; import { Search, Tag, Plus, X, ChevronDown, MoreHorizontal, Pause, Play, XCircle, Trash2, Layers, Package, FolderKanban } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore'; import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore'; import { useRequirementStore } from '@/stores/useRequirementStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
@@ -11,15 +11,15 @@ import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore'; import { useMemberStore } from '@/stores/useMemberStore';
import { flattenVersions, flattenProjects } from '@/lib/derive'; import { flattenVersions } from '@/lib/derive';
import type { VersionWithContext } 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 { VersionStatus } from '@/lib/version-status';
import { STAGES } from '@/lib/stage';
import { ROLE_LABEL } from '@/lib/stage'; import { ROLE_LABEL } from '@/lib/stage';
import { buildVersionProgressMap } from '@/lib/version-progress'; import { buildVersionProgressMap } from '@/lib/version-progress';
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
import { getRiskScoreTone } from '@/lib/xiaobao-warning-view'; import { getRiskScoreTone } from '@/lib/xiaobao-warning-view';
import { buildVersionScopeTree, filterVersionsForList, type VersionListScope, type VersionProductNode } from '@/lib/version-list';
import { import {
VERSION_DEVELOPMENT_TYPE_OPTIONS, VERSION_DEVELOPMENT_TYPE_OPTIONS,
canSubmitNewVersionForm, canSubmitNewVersionForm,
@@ -60,24 +60,6 @@ const RISK_SCORE_DOT_CLASS = {
ok: 'bg-emerald-500', ok: 'bg-emerald-500',
} as const; } as const;
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[] { function sortVersions(versions: VersionWithContext[]): VersionWithContext[] {
return [...versions].sort((a, b) => { return [...versions].sort((a, b) => {
// Sort by priority desc (P0 first) // Sort by priority desc (P0 first)
@@ -89,28 +71,6 @@ function sortVersions(versions: VersionWithContext[]): VersionWithContext[] {
}); });
} }
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() { export default function VersionsPage() {
return ( return (
<RouteGuard permission="version:view"> <RouteGuard permission="version:view">
@@ -122,10 +82,10 @@ export default function VersionsPage() {
function VersionsPageContent() { function VersionsPageContent() {
const router = useRouter(); const router = useRouter();
const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore(); const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore();
const [search, setSearch] = useState(''); const [versionKeyword, setVersionKeyword] = useState('');
const [projectFilter, setProjectFilter] = useState('all'); const [treeKeyword, setTreeKeyword] = useState('');
const [selectedScope, setSelectedScope] = useState<VersionListScope>({ type: 'all' });
const [priorityFilter, setPriorityFilter] = useState('all'); const [priorityFilter, setPriorityFilter] = useState('all');
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false);
const [priorityDropdownOpen, setPriorityDropdownOpen] = useState(false); const [priorityDropdownOpen, setPriorityDropdownOpen] = useState(false);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null); const [openMenuId, setOpenMenuId] = useState<string | null>(null);
@@ -164,7 +124,7 @@ function VersionsPageContent() {
if (!v.members || v.members.length === 0) return true; if (!v.members || v.members.length === 0) return true;
return v.members.some((m) => m.name === currentUserName); return v.members.some((m) => m.name === currentUserName);
}), [allVersionsRaw, currentUserName, isSuperAdmin]); }), [allVersionsRaw, currentUserName, isSuperAdmin]);
const allProjects = useMemo(() => flattenProjects(overview), [overview]); const versionTree = useMemo(() => buildVersionScopeTree(allVersions, treeKeyword), [allVersions, treeKeyword]);
// Compute overall progress per version from actual data // Compute overall progress per version from actual data
const versionProgressMap = useMemo( const versionProgressMap = useMemo(
@@ -173,21 +133,27 @@ function VersionsPageContent() {
); );
const filtered = useMemo(() => { const filtered = useMemo(() => {
let list = allVersions.filter((v) => { const list = filterVersionsForList(allVersions, {
if (search && !v.name.toLowerCase().includes(search.toLowerCase())) return false; scope: selectedScope,
if (projectFilter !== 'all' && v.projectName !== projectFilter) return false; keyword: versionKeyword,
return true; priority: priorityFilter as Priority | 'all',
}); });
if (priorityFilter !== 'all') {
list = list.filter(v => (v.priority ?? 'P2') === priorityFilter);
}
return sortVersions(list); return sortVersions(list);
}, [allVersions, search, projectFilter, priorityFilter]); }, [allVersions, selectedScope, versionKeyword, priorityFilter]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20); 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') => { const handleAction = async (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => {
setOpenMenuId(null); setOpenMenuId(null);
if (action === 'delete') { if (action === 'delete') {
@@ -207,116 +173,112 @@ function VersionsPageContent() {
}; };
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full overflow-hidden">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5"> <VersionScopeSidebar
<div className="flex items-center gap-2.5"> tree={versionTree}
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]"></h1> totalCount={allVersions.length}
<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> keyword={treeKeyword}
</div> selectedScope={selectedScope}
<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"> onKeywordChange={setTreeKeyword}
<Plus className="h-3.5 w-3.5" strokeWidth={2} /> onSelectScope={(scope) => {
setSelectedScope(scope);
</button> setPage(1);
</header> }}
/>
{/* Filters */} <section className="flex min-w-0 flex-1 flex-col bg-[var(--bg)]">
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3"> <header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="relative"> <div className="min-w-0">
<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} /> <div className="flex items-center gap-2.5">
<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)]" /> <h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]"></h1>
</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)]">{filtered.length}</span>
</div>
{/* Project dropdown */} <p className="mt-0.5 truncate text-[11px] text-[var(--ink-muted)]">{selectedScopeLabel}</p>
<div className="relative"> </div>
<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"> <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)]">
{projectFilter === 'all' ? '全部项目' : projectFilter} <Plus className="h-3.5 w-3.5" strokeWidth={2} />
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</button> </button>
{projectDropdownOpen && ( </header>
<>
<div className="fixed inset-0 z-10" onClick={() => setProjectDropdownOpen(false)} /> <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="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)]"> <div className="relative">
<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)]'}`}> <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
</button> value={versionKeyword}
{allProjects.map((p) => ( onChange={(event) => { setVersionKeyword(event.target.value); setPage(1); }}
<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)]'}`}> placeholder="搜索版本号"
{p.name} 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> </button>
))} {(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((priority) => (
</div> <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>
{/* Priority dropdown */} <div className="min-h-0 flex-1 overflow-y-auto p-5">
<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 ? ( {filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center"> <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="text-[13px] font-medium text-[var(--ink-soft)]"></p>
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]"></p> <p className="mt-1.5 text-[12px] text-[var(--ink-muted)]"></p>
</div> </div>
) : ( ) : (
<> <>
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]"> <div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]"> <div className="overflow-x-auto">
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]"> <table className="min-w-[920px] w-full text-left text-[13px]">
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]"> <thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th> <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> <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> <th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
</thead> </tr>
<tbody> </thead>
{paged.map((v) => ( <tbody>
<VersionRow {paged.map((version) => (
key={v.id} <VersionRow
version={v} key={version.id}
overallProgress={versionProgressMap[v.id] ?? 0} version={version}
xiaobaoRisk={xiaobaoRiskMap.get(v.id)} overallProgress={versionProgressMap[version.id] ?? 0}
openMenuId={openMenuId} xiaobaoRisk={xiaobaoRiskMap.get(version.id)}
setOpenMenuId={setOpenMenuId} openMenuId={openMenuId}
onNavigate={() => router.push(`/versions/${v.id}`)} setOpenMenuId={setOpenMenuId}
onAction={handleAction} onNavigate={() => router.push(`/versions/${version.id}`)}
/> onAction={handleAction}
))} />
</tbody> ))}
</table> </tbody>
</div> </table>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} /> </div>
</div>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
</> </>
)} )}
</div> </div>
</div> </section>
{showModal && <NewVersionModal overview={overview} onClose={() => setShowModal(false)} onCreate={createVersion} currentUserName={currentUserName} />} {showModal && <NewVersionModal overview={overview} onClose={() => setShowModal(false)} onCreate={createVersion} currentUserName={currentUserName} />}
</div> </div>
@@ -333,6 +295,93 @@ interface VersionRowProps {
onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => 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) { function VersionRow({ version, overallProgress, xiaobaoRisk, openMenuId, setOpenMenuId, onNavigate, onAction }: VersionRowProps) {
const priority = (version.priority ?? 'P2') as Priority; const priority = (version.priority ?? 'P2') as Priority;
const riskScoreTone = xiaobaoRisk ? getRiskScoreTone(xiaobaoRisk.riskScore) : null; const riskScoreTone = xiaobaoRisk ? getRiskScoreTone(xiaobaoRisk.riskScore) : null;

View File

@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const nextConfig = require('../../next.config.js');
test('next dev ignores test compiler output to avoid route refresh interruptions', () => {
assert.equal(typeof nextConfig.webpack, 'function');
const config = nextConfig.webpack({ watchOptions: { ignored: ['**/node_modules/**'] } }, { dev: true });
const ignored = config.watchOptions?.ignored;
const ignoredList = Array.isArray(ignored) ? ignored : [ignored];
assert.ok(ignoredList.includes('**/.tmp-test/**'));
});
test('next dev keeps watch ignored entries compatible with webpack schema', () => {
const config = nextConfig.webpack({ watchOptions: { ignored: [/node_modules/] } }, { dev: true });
const ignored = config.watchOptions?.ignored;
const ignoredList = Array.isArray(ignored) ? ignored : [ignored];
assert.deepEqual(ignoredList, ['**/.tmp-test/**']);
});

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildVersionScopeTree, filterVersionsForList } from './version-list';
import type { VersionWithContext } from './derive';
function version(input: Partial<VersionWithContext> & Pick<VersionWithContext, 'id' | 'name' | 'productId' | 'productName' | 'projectId' | 'projectName'>): VersionWithContext {
return {
status: 'developing',
releaseDate: null,
createdAt: '2026-07-01T00:00:00.000Z',
...input,
};
}
const versions = [
version({ id: 'v-a', name: 'AlphaV1.0', productId: 'prod-a', productName: '产品A', projectId: 'proj-a', projectName: 'Alpha', priority: 'P1' }),
version({ id: 'v-b', name: 'AlphaV1.1', productId: 'prod-a', productName: '产品A', projectId: 'proj-a', projectName: 'Alpha', priority: 'P2' }),
version({ id: 'v-c', name: 'BetaV1.0', productId: 'prod-a', productName: '产品A', projectId: 'proj-b', projectName: 'Beta', priority: 'P0' }),
version({ id: 'v-d', name: 'GammaV1.0', productId: 'prod-b', productName: '产品B', projectId: 'proj-c', projectName: 'Gamma', priority: 'P3' }),
];
test('buildVersionScopeTree groups versions by product and project with counts', () => {
const tree = buildVersionScopeTree(versions, '');
assert.equal(tree.length, 2);
assert.equal(tree[0].count, 3);
assert.equal(tree[0].projects[0].count, 2);
assert.equal(tree[0].projects[1].count, 1);
});
test('buildVersionScopeTree filters the tree by product project or version keyword', () => {
assert.deepEqual(buildVersionScopeTree(versions, 'Beta').map((node) => node.projects.map((project) => project.projectName)), [['Beta']]);
assert.deepEqual(buildVersionScopeTree(versions, 'GammaV1.0').map((node) => node.productName), ['产品B']);
});
test('filterVersionsForList applies scope keyword and priority filters', () => {
assert.deepEqual(
filterVersionsForList(versions, { scope: { type: 'project', projectId: 'proj-a' }, keyword: '1.1', priority: 'all' }).map((item) => item.id),
['v-b'],
);
assert.deepEqual(
filterVersionsForList(versions, { scope: { type: 'product', productId: 'prod-a' }, keyword: '', priority: 'P0' }).map((item) => item.id),
['v-c'],
);
});

View File

@@ -0,0 +1,86 @@
import type { Priority, VersionWithContext } from './derive';
export type VersionListScope =
| { type: 'all' }
| { type: 'product'; productId: string }
| { type: 'project'; projectId: string };
export interface VersionProjectNode {
projectId: string;
projectName: string;
count: number;
}
export interface VersionProductNode {
productId: string;
productName: string;
count: number;
projects: VersionProjectNode[];
}
export interface VersionListFilter {
scope: VersionListScope;
keyword: string;
priority: Priority | 'all';
}
function matchesKeyword(values: Array<string | undefined | null>, keyword: string): boolean {
const normalized = keyword.trim().toLowerCase();
if (!normalized) return true;
return values.some((value) => value?.toLowerCase().includes(normalized));
}
export function buildVersionScopeTree(versions: VersionWithContext[], keyword: string): VersionProductNode[] {
const normalized = keyword.trim().toLowerCase();
const productMap = new Map<string, {
productId: string;
productName: string;
projects: Map<string, VersionProjectNode>;
}>();
for (const version of versions) {
const productMatches = matchesKeyword([version.productName], normalized);
const projectMatches = matchesKeyword([version.projectName], normalized);
const versionMatches = matchesKeyword([version.name], normalized);
if (normalized && !productMatches && !projectMatches && !versionMatches) continue;
let product = productMap.get(version.productId);
if (!product) {
product = {
productId: version.productId,
productName: version.productName,
projects: new Map(),
};
productMap.set(version.productId, product);
}
const project = product.projects.get(version.projectId) ?? {
projectId: version.projectId,
projectName: version.projectName,
count: 0,
};
project.count += 1;
product.projects.set(version.projectId, project);
}
return Array.from(productMap.values()).map((product) => {
const projects = Array.from(product.projects.values()).sort((a, b) => a.projectName.localeCompare(b.projectName, 'zh-Hans-CN'));
return {
productId: product.productId,
productName: product.productName,
count: projects.reduce((sum, project) => sum + project.count, 0),
projects,
};
}).sort((a, b) => a.productName.localeCompare(b.productName, 'zh-Hans-CN'));
}
export function filterVersionsForList(versions: VersionWithContext[], filter: VersionListFilter): VersionWithContext[] {
const filtered = versions.filter((version) => {
if (filter.scope.type === 'product' && version.productId !== filter.scope.productId) return false;
if (filter.scope.type === 'project' && version.projectId !== filter.scope.projectId) return false;
if (filter.priority !== 'all' && (version.priority ?? 'P2') !== filter.priority) return false;
return matchesKeyword([version.name], filter.keyword);
});
return filtered;
}

View File

@@ -1,6 +1,24 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const TEST_OUTPUT_GLOB = '**/.tmp-test/**';
function toIgnoredList(ignored) {
if (!ignored) return [];
const entries = Array.isArray(ignored) ? ignored : [ignored];
return entries.filter((entry) => typeof entry === 'string' && entry.length > 0);
}
const nextConfig = { const nextConfig = {
transpilePackages: ['@ftb/shared'], transpilePackages: ['@ftb/shared'],
webpack(config, { dev }) {
if (dev) {
const ignored = toIgnoredList(config.watchOptions?.ignored);
config.watchOptions = {
...config.watchOptions,
ignored: ignored.includes(TEST_OUTPUT_GLOB) ? ignored : [...ignored, TEST_OUTPUT_GLOB],
};
}
return config;
},
}; };
module.exports = nextConfig; module.exports = nextConfig;